From b915654e9744444c8c871e751f486d48f1d2ea33 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 10:56:49 +0100 Subject: [PATCH 001/144] #15 - Adding flake (NixOS) --- backend/flake.lock | 27 +++++++++++++++++++++++++++ backend/flake.nix | 23 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 backend/flake.lock create mode 100644 backend/flake.nix diff --git a/backend/flake.lock b/backend/flake.lock new file mode 100644 index 00000000..f4191007 --- /dev/null +++ b/backend/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1708723535, + "narHash": "sha256-1z+3BHE9o1TfMpp7QAGAfu4+znaQv/47hIaV3n6HAuA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "27c12cd057b9dcd903a0ffb6a0712199cf4a66e1", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/backend/flake.nix b/backend/flake.nix new file mode 100644 index 00000000..b19eba33 --- /dev/null +++ b/backend/flake.nix @@ -0,0 +1,23 @@ +{ + description = "Python pip DevShell"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + + outputs = { self, nixpkgs }: let + systems = [ "x86_64-linux" ]; + forAllSystems = function: nixpkgs.lib.genAttrs systems (system: function { + pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; + }); + in { + devShells = forAllSystems ({ pkgs }: { + default = (pkgs.buildFHSUserEnv { + name = "pip-zone"; + targetPkgs = pkgs: with pkgs; [ + python311Full + python311Packages.pip + ]; + runScript = "zsh || bash"; + }).env; + }); + }; +} From 42c19dbc4e3b67e41550ac97ee1023badacb589d Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 14:03:31 +0100 Subject: [PATCH 002/144] #15 - HTTP methods (skeleton) --- backend/project/__init__.py | 2 + backend/project/endpoints/submissions.py | 70 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 backend/project/endpoints/submissions.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 66435003..5ed5e524 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from .endpoints.index import index_bp +from .endpoints.submissions import submissions_bp db = SQLAlchemy() @@ -17,6 +18,7 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(submissions_bp) return app diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py new file mode 100644 index 00000000..7c27bb86 --- /dev/null +++ b/backend/project/endpoints/submissions.py @@ -0,0 +1,70 @@ +"""Submission API endpoint""" + +from flask import Blueprint +from flask_restful import Resource + +submissions_bp = Blueprint("submissions", __name__) + +class Submissions(Resource): + """API endpoint for the submissions""" + + def get(self, uid: int, pid: int) -> dict[str, int]: + """Get all the submissions from a user for a project + + Args: + uid (int): User ID + pid (int): Project ID + + Returns: + dict[str, int]: The list of submission URLs + """ + return {"uid": uid, "pid": pid} + + def post(self, uid: int, pid: int) -> dict[str, int]: + """Post a new submission to a project + + Args: + uid (int): User ID + pid (int): Project ID + + Returns: + dict[str, int]: The URL to the submission + """ + return {"uid": uid, "pid": pid} + +submissions_bp.add_url_rule( + "/submissions//", + view_func=Submissions.as_view("submissions")) + +class Submission(Resource): + """API endpoint for the submission""" + + def get(self, uid: int, pid: int, sid: int) -> dict[str, int]: + """Get the submission given an submission ID + + Args: + uid (int): User ID + pid (int): Project ID + sid (int): Submission ID + + Returns: + dict[str, int]: The submission + """ + return {"uid": uid, "pid": pid, "sid": sid} + + def delete(self, uid: int, pid: int, sid: int) -> dict[str, int]: + """Delete a submission given an submission ID + + Args: + uid (int): User ID + pid (int): Project ID + sid (int): Submission ID + + Returns: + dict[str, int]: Empty + """ + return {"uid": uid, "pid": pid, "sid": sid} + +submissions_bp.add_url_rule( + "/submissions///", + view_func=Submission.as_view("submission")) From cabc3fe8400de320fe13050c495aaf540d7060c6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 15:44:55 +0100 Subject: [PATCH 003/144] #15 - Updating flake with database tools --- backend/flake.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/flake.nix b/backend/flake.nix index b19eba33..3d88fde4 100644 --- a/backend/flake.nix +++ b/backend/flake.nix @@ -15,6 +15,9 @@ targetPkgs = pkgs: with pkgs; [ python311Full python311Packages.pip + + postgresql + dbeaver ]; runScript = "zsh || bash"; }).env; From 80f33a4f55895d83f855797c07d9082045b729de Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 22:05:33 +0100 Subject: [PATCH 004/144] #15 - Local Flask server that can access a local database --- backend/project/__init__.py | 22 ++++++++++++++++++---- backend/project/__main__.py | 13 ++++++++++++- backend/project/database.py | 5 +++++ backend/project/endpoints/submissions.py | 10 +++++++--- backend/project/models/submissions.py | 2 +- 5 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 backend/project/database.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 5d79d95d..3b0c137b 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -2,13 +2,14 @@ This file is the base of the Flask API. It contains the basic structure of the API. """ +from os import getenv +from dotenv import load_dotenv +from sqlalchemy import URL from flask import Flask -from flask_sqlalchemy import SQLAlchemy +from .database import db from .endpoints.index.index import index_bp from .endpoints.submissions import submissions_bp -db = SQLAlchemy() - def create_app(): """ Create a Flask application instance. @@ -22,7 +23,7 @@ def create_app(): return app -def create_app_with_db(db_uri:str): +def create_app_with_db(db_uri: str = None): """ Initialize the database with the given uri and connect it to the app made with create_app. @@ -31,6 +32,19 @@ def create_app_with_db(db_uri:str): Returns: Flask -- A Flask application instance """ + + #$ flask --app project:create_app_with_db run + if db_uri is None: + load_dotenv() + db_uri = URL.create( + drivername=getenv("DB_DRIVER"), + username=getenv("DB_USER"), + password=getenv("DB_PASSWORD"), + host=getenv("DB_HOST"), + port=int(getenv("DB_PORT")), + database=getenv("DB_NAME") + ) + app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri db.init_app(app) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 9448b0eb..22c2a22e 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -2,11 +2,22 @@ from sys import path from os import getenv from dotenv import load_dotenv +from sqlalchemy import URL from project import create_app_with_db path.append(".") if __name__ == "__main__": load_dotenv() - app = create_app_with_db(getenv("DB_HOST")) + + url = URL.create( + drivername=getenv("DB_DRIVER"), + username=getenv("DB_USER"), + password=getenv("DB_PASSWORD"), + host=getenv("DB_HOST"), + port=int(getenv("DB_PORT")), + database=getenv("DB_NAME") + ) + + app = create_app_with_db(url) app.run(debug=True) diff --git a/backend/project/database.py b/backend/project/database.py new file mode 100644 index 00000000..b62b38c9 --- /dev/null +++ b/backend/project/database.py @@ -0,0 +1,5 @@ +"""Database file""" + +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 7c27bb86..64b859fc 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,13 +2,14 @@ from flask import Blueprint from flask_restful import Resource +from project.models.submissions import Submissions as m_submissions submissions_bp = Blueprint("submissions", __name__) class Submissions(Resource): """API endpoint for the submissions""" - def get(self, uid: int, pid: int) -> dict[str, int]: + def get(self, uid: str, pid: int) -> dict[str, any]: """Get all the submissions from a user for a project Args: @@ -18,7 +19,10 @@ def get(self, uid: int, pid: int) -> dict[str, int]: Returns: dict[str, int]: The list of submission URLs """ - return {"uid": uid, "pid": pid} + + a = m_submissions.query.filter_by(uid=uid, project_id=pid).count() + + return {"uid": uid, "pid": pid, "test": a} def post(self, uid: int, pid: int) -> dict[str, int]: """Post a new submission to a project @@ -33,7 +37,7 @@ def post(self, uid: int, pid: int) -> dict[str, int]: return {"uid": uid, "pid": pid} submissions_bp.add_url_rule( - "/submissions//", + "/submissions//", view_func=Submissions.as_view("submissions")) class Submission(Resource): diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py index 97e8762c..a612b48d 100644 --- a/backend/project/models/submissions.py +++ b/backend/project/models/submissions.py @@ -1,7 +1,7 @@ """Model for submissions""" from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean -from project import db +from project.database import db class Submissions(db.Model): """This class describes the submissions table, From 434f179c80c887d6e2a62aca79e6accac8abb4cb Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 15:11:15 +0100 Subject: [PATCH 005/144] #15 - Fix linter use of duplicate code fragment --- backend/project/__init__.py | 16 ++-------------- backend/project/__main__.py | 18 +++--------------- backend/project/database.py | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 3b0c137b..ec71b0f8 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -2,11 +2,8 @@ This file is the base of the Flask API. It contains the basic structure of the API. """ -from os import getenv -from dotenv import load_dotenv -from sqlalchemy import URL from flask import Flask -from .database import db +from .database import db, get_database_uri from .endpoints.index.index import index_bp from .endpoints.submissions import submissions_bp @@ -20,7 +17,6 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) app.register_blueprint(submissions_bp) - return app def create_app_with_db(db_uri: str = None): @@ -35,15 +31,7 @@ def create_app_with_db(db_uri: str = None): #$ flask --app project:create_app_with_db run if db_uri is None: - load_dotenv() - db_uri = URL.create( - drivername=getenv("DB_DRIVER"), - username=getenv("DB_USER"), - password=getenv("DB_PASSWORD"), - host=getenv("DB_HOST"), - port=int(getenv("DB_PORT")), - database=getenv("DB_NAME") - ) + db_uri = get_database_uri() app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 22c2a22e..8945aa1f 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,23 +1,11 @@ """Main entry point for the application.""" + from sys import path -from os import getenv -from dotenv import load_dotenv -from sqlalchemy import URL from project import create_app_with_db +from project.database import get_database_uri path.append(".") if __name__ == "__main__": - load_dotenv() - - url = URL.create( - drivername=getenv("DB_DRIVER"), - username=getenv("DB_USER"), - password=getenv("DB_PASSWORD"), - host=getenv("DB_HOST"), - port=int(getenv("DB_PORT")), - database=getenv("DB_NAME") - ) - - app = create_app_with_db(url) + app = create_app_with_db(get_database_uri()) app.run(debug=True) diff --git a/backend/project/database.py b/backend/project/database.py index b62b38c9..1c879be5 100644 --- a/backend/project/database.py +++ b/backend/project/database.py @@ -1,5 +1,25 @@ """Database file""" +from os import getenv from flask_sqlalchemy import SQLAlchemy +from dotenv import load_dotenv +from sqlalchemy import URL db = SQLAlchemy() + +def get_database_uri() -> str: + """Get the database URI made from environment variables + + Returns: + str: Database URI + """ + load_dotenv() + uri = URL.create( + drivername=getenv("DB_DRIVER"), + username=getenv("DB_USER"), + password=getenv("DB_PASSWORD"), + host=getenv("DB_HOST"), + port=int(getenv("DB_PORT")), + database=getenv("DB_NAME") + ) + return uri From c5399f5afe26a8745db546e814b3f766624e0b0e Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 22:32:12 +0100 Subject: [PATCH 006/144] #15 - Most methods implemented. Still linting issues, submissions patch needed, tests, authentication --- backend/project/__main__.py | 2 +- backend/project/endpoints/submissions.py | 178 ++++++++++++++++++++--- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 8945aa1f..4980ef8a 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -2,7 +2,7 @@ from sys import path from project import create_app_with_db -from project.database import get_database_uri +from .database import get_database_uri path.append(".") diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 64b859fc..631ec927 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -1,8 +1,12 @@ """Submission API endpoint""" -from flask import Blueprint +from datetime import datetime +from flask import Blueprint, request from flask_restful import Resource +from project.database import db from project.models.submissions import Submissions as m_submissions +from project.models.projects import Projects as m_projects +from project.models.users import Users as m_users submissions_bp = Blueprint("submissions", __name__) @@ -13,28 +17,106 @@ def get(self, uid: str, pid: int) -> dict[str, any]: """Get all the submissions from a user for a project Args: - uid (int): User ID + uid (str): User ID pid (int): Project ID Returns: - dict[str, int]: The list of submission URLs + dict[str, any]: The list of submission URLs """ - a = m_submissions.query.filter_by(uid=uid, project_id=pid).count() - - return {"uid": uid, "pid": pid, "test": a} - - def post(self, uid: int, pid: int) -> dict[str, int]: + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Check user + user = session.get(m_users, uid) + if user is None: + return {"message": f"User {uid} not found"}, 404 + + # Check project + project = session.get(m_projects, pid) + if project is None: + return {"message": f"Project {pid} not found"}, 404 + + # Get the submissions + submissions = session.query(m_submissions).filter_by(uid=uid, project_id=pid).all() + submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] + return {"submissions": submissions_urls} + except Exception: + return {"message": f"An error occurred while fetching the submissions from user {uid} for project {pid}"}, 500 + + def post(self, uid: str, pid: int) -> dict[str, any]: """Post a new submission to a project Args: - uid (int): User ID + uid (str): User ID pid (int): Project ID Returns: - dict[str, int]: The URL to the submission + dict[str, any]: The URL to the submission """ - return {"uid": uid, "pid": pid} + + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if uid_operator != uid: + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + submission = m_submissions() + + # User + user = session.get(m_users, uid) + if user is None: + return {"message": f"User {uid} not found"}, 404 + submission.uid = uid + + # Project + project = session.get(m_projects, pid) + if project is None: + return {"message": f"Project {pid} not found"}, 404 + submission.project_id = pid + + # Grading + if "grading" in request.form: + grading = request.form["grading"] + if grading < 0 or grading > 20: + return {} + submission.grading = grading + + # Submission time + submission.submission_time = datetime.now() + + # Submission path + # get the files and store them + submission.submission_path = "/tbd" + + # Submission status + submission.submission_status = False + + session.add(submission) + session.commit() + return {"submission": f"/submissions/{submission.submission_id}"}, 201 + except Exception: + session.rollback() + return {"message": f"An error occurred while creating a new submission for user {uid} in project {pid}"}, 500 submissions_bp.add_url_rule( "/submissions//", @@ -43,32 +125,84 @@ def post(self, uid: int, pid: int) -> dict[str, int]: class Submission(Resource): """API endpoint for the submission""" - def get(self, uid: int, pid: int, sid: int) -> dict[str, int]: + def get(self, sid: int) -> dict[str, any]: """Get the submission given an submission ID Args: - uid (int): User ID - pid (int): Project ID sid (int): Submission ID Returns: - dict[str, int]: The submission + dict[str, any]: The submission """ - return {"uid": uid, "pid": pid, "sid": sid} - def delete(self, uid: int, pid: int, sid: int) -> dict[str, int]: + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Get the submission + submission = session.get(m_submissions, sid) + if submission is None: + return {"message": f"Submission {sid} not found"}, 404 + + return { + "submission_id": submission.submission_id, + "uid": submission.uid, + "project_id": submission.project_id, + "grading": submission.grading, + "submission_time": submission.submission_time, + "submission_path": submission.submission_path, + "submission_status": submission.submission_status + } + except Exception: + return {"message": f"An error occurred while fetching submission {sid}"}, 500 + + def delete(self, sid: int) -> dict[str, any]: """Delete a submission given an submission ID Args: - uid (int): User ID - pid (int): Project ID sid (int): Submission ID Returns: - dict[str, int]: Empty + dict[str, any]: Empty """ - return {"uid": uid, "pid": pid, "sid": sid} + + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not operator.is_admin: + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Check if the submission exists + submission = session.get(m_submissions, sid) + if submission is None: + return {"message": f"Submission {sid} not found"}, 404 + + # Delete the submission + session.delete(submission) + session.commit() + return {"message": f"Submission {sid} deleted"} + except Exception: + db.session.rollback() + return {"message": f"An error occurred while deleting submission {sid}"}, 500 submissions_bp.add_url_rule( - "/submissions///", + "/submissions/", view_func=Submission.as_view("submission")) From c857aeec417db666b091dab41bfd232cbee20d65 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 22:48:41 +0100 Subject: [PATCH 007/144] #15 - Adding patch method so teachers can update the grade --- backend/project/endpoints/submissions.py | 47 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 631ec927..26c13980 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -98,7 +98,7 @@ def post(self, uid: str, pid: int) -> dict[str, any]: if "grading" in request.form: grading = request.form["grading"] if grading < 0 or grading > 20: - return {} + return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 submission.grading = grading # Submission time @@ -166,6 +166,49 @@ def get(self, sid: int) -> dict[str, any]: except Exception: return {"message": f"An error occurred while fetching submission {sid}"}, 500 + def patch(self, sid:int) -> dict[str, any]: + """Update some fields of a submission given a submission ID + + Args: + sid (int): Submission ID + + Returns: + dict[str, any]: A message + """ + + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not operator.is_teacher: + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Get the submission + submission = session.get(m_submissions, sid) + if submission is None: + return {"message": f"Submission {sid} not found"}, 404 + + # Update the grading field (its the only field that a teacher can update) + if "grading" in request.form: + grading = request.form["grading"] + if grading < 0 or grading > 20: + return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 + submission.grading = grading + + # Save the submission + session.commit() + return {"message": "Submission {sid} updated"} + except Exception: + session.rollback() + return {"message": f"An error occurred while patching submission {sid}"}, 500 + def delete(self, sid: int) -> dict[str, any]: """Delete a submission given an submission ID @@ -173,7 +216,7 @@ def delete(self, sid: int) -> dict[str, any]: sid (int): Submission ID Returns: - dict[str, any]: Empty + dict[str, any]: A message """ # Authentication From d2f4b706c7eea1c4e7003c86a61a72984c8618a5 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 23:07:41 +0100 Subject: [PATCH 008/144] #15 - Fixed linter, only 'broad-exception-caught' but dont have a solution right now --- backend/project/endpoints/submissions.py | 34 +++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 26c13980..caf8b5b2 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -36,7 +36,9 @@ def get(self, uid: str, pid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Check user user = session.get(m_users, uid) @@ -53,7 +55,8 @@ def get(self, uid: str, pid: int) -> dict[str, any]: submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] return {"submissions": submissions_urls} except Exception: - return {"message": f"An error occurred while fetching the submissions from user {uid} for project {pid}"}, 500 + return {"message": f"An error occurred while fetching the submissions " + f"from user {uid} for project {pid}"}, 500 def post(self, uid: str, pid: int) -> dict[str, any]: """Post a new submission to a project @@ -78,7 +81,9 @@ def post(self, uid: str, pid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if uid_operator != uid: - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 submission = m_submissions() @@ -98,7 +103,9 @@ def post(self, uid: str, pid: int) -> dict[str, any]: if "grading" in request.form: grading = request.form["grading"] if grading < 0 or grading > 20: - return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 + return { + "message": "The submission must have a 'grading' in between 0-20" + }, 400 submission.grading = grading # Submission time @@ -116,7 +123,8 @@ def post(self, uid: str, pid: int) -> dict[str, any]: return {"submission": f"/submissions/{submission.submission_id}"}, 201 except Exception: session.rollback() - return {"message": f"An error occurred while creating a new submission for user {uid} in project {pid}"}, 500 + return {"message": f"An error occurred while creating a new submission " + f"for user {uid} in project {pid}"}, 500 submissions_bp.add_url_rule( "/submissions//", @@ -147,7 +155,9 @@ def get(self, sid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Get the submission submission = session.get(m_submissions, sid) @@ -188,7 +198,9 @@ def patch(self, sid:int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not operator.is_teacher: - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Get the submission submission = session.get(m_submissions, sid) @@ -199,7 +211,9 @@ def patch(self, sid:int) -> dict[str, any]: if "grading" in request.form: grading = request.form["grading"] if grading < 0 or grading > 20: - return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 + return { + "message": "The submission must have a 'grading' in between 0-20" + }, 400 submission.grading = grading # Save the submission @@ -231,7 +245,9 @@ def delete(self, sid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not operator.is_admin: - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Check if the submission exists submission = session.get(m_submissions, sid) From 01bd8a811520d781e8341879d89008c6f30f7d05 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Mon, 26 Feb 2024 18:37:03 +0100 Subject: [PATCH 009/144] #15 - Updating the OpenAPI specifications --- .../endpoints/index/OpenAPI_Object.json | 373 +++++++++++++++++- backend/project/endpoints/submissions.py | 9 +- 2 files changed, 376 insertions(+), 6 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 7243ff59..7b855bbd 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -41,5 +41,376 @@ } ] }, - "paths": [] + "paths": { + "/submissions/{uid}/{pid}": { + "get": { + "summary": "Get all submissions from a user for a project", + "responses": { + "200": { + "description": "A list of submission URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submissions": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Post a new submission to a project", + "requestBody": { + "description": "Grading", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "int", + "minimum": 0, + "maximum": 20 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The newly created submission URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submission": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "A 'bad data field' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pid", + "in": "path", + "description": "Project ID", + "required": true, + "schema": { + "type": "int" + } + } + ] + }, + "/submissions/{sid}": { + "get": { + "summary": "Get the submission", + "responses": { + "200": { + "description": "The submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submission_id": { + "type": "int" + }, + "uid": { + "type": "string" + }, + "project_id": { + "type": "int" + }, + "grading": { + "type": "int" + }, + "submission_time": { + "type": "string" + }, + "submission_path": { + "type": "string" + }, + "submission_status": { + "type": "int" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "patch": { + "summary": "Update the submission", + "requestBody": { + "description": "The submission data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "int", + "minimum": 0, + "maximum": 20 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A 'submission updated' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "A 'bad data field' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "summary": "Delete the submission", + "responses": { + "200": { + "description": "A 'submission deleted' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "sid", + "in": "path", + "description": "Submission ID", + "required": true, + "schema": { + "type": "int" + } + } + ] + } + } } diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index caf8b5b2..f6c4e4d8 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -126,10 +126,6 @@ def post(self, uid: str, pid: int) -> dict[str, any]: return {"message": f"An error occurred while creating a new submission " f"for user {uid} in project {pid}"}, 500 -submissions_bp.add_url_rule( - "/submissions//", - view_func=Submissions.as_view("submissions")) - class Submission(Resource): """API endpoint for the submission""" @@ -218,7 +214,7 @@ def patch(self, sid:int) -> dict[str, any]: # Save the submission session.commit() - return {"message": "Submission {sid} updated"} + return {"message": f"Submission {sid} updated"} except Exception: session.rollback() return {"message": f"An error occurred while patching submission {sid}"}, 500 @@ -262,6 +258,9 @@ def delete(self, sid: int) -> dict[str, any]: db.session.rollback() return {"message": f"An error occurred while deleting submission {sid}"}, 500 +submissions_bp.add_url_rule( + "/submissions//", + view_func=Submissions.as_view("submissions")) submissions_bp.add_url_rule( "/submissions/", view_func=Submission.as_view("submission")) From 0ed1a967fea390c039e057321e95d57a53e39f4b Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 28 Feb 2024 20:59:44 +0100 Subject: [PATCH 010/144] #15 - Skeleton for tests --- backend/pylintrc | 1 + backend/tests/endpoints/conftest.py | 27 +++++++- backend/tests/endpoints/submissions_test.py | 75 +++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 backend/tests/endpoints/submissions_test.py diff --git a/backend/pylintrc b/backend/pylintrc index 83eff274..86b66862 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -4,6 +4,7 @@ init-hook='import sys; sys.path.append(".")' [test-files:*_test.py] disable= W0621, # Redefining name %r from outer scope (line %s) + R0904, # Too many public methods (too many unit tests essentially) [modules:project/modules/*] disable= diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 010ef293..88bdb75a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,6 +1,29 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest -from project import create_app +from project import create_app_with_db +from project.database import db, get_database_uri + +@pytest.fixture +def session(): + """Create a database session for the tests""" + # Create all tables + db.create_all() + + # Populate the database + db.session.commit() + + # Tests can now use a populated database + yield db.session + + # Rollback + db.session.rollback() + + # Remove all tables + for table in reversed(db.metadata.sorted_tables): + db.session.execute(table.delete()) + db.session.commit() + + db.session.close() @pytest.fixture def app(): @@ -8,7 +31,7 @@ def app(): Returns: Flask -- A Flask application instance """ - app = create_app() + app = create_app_with_db(get_database_uri()) yield app @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py new file mode 100644 index 00000000..e5f3e856 --- /dev/null +++ b/backend/tests/endpoints/submissions_test.py @@ -0,0 +1,75 @@ +"""Test the submissions API endpoint""" + +class TestSubmissionsEndpoint: + """Class to test the submissions API endpoint""" + + ### GET SUBMISSIONS ### + def test_get_submissions_wrong_user(self, client, session): + """Test getting submissions for a non-existing user""" + + def test_get_submissions_wrong_project(self, client, session): + """Test getting submissions for a non-existing project""" + + def test_get_submissions_database_issue(self, client, session): + """Test getting the submissions with a faulty database""" + + def test_get_submissions_correct(self, client, session): + """Test getting the submissions""" + + ### POST SUBMISSIONS ### + def test_post_submissions_wrong_user(self, client, session): + """Test posting a submission for a non-existing user""" + + def test_post_submissions_wrong_project(self, client, session): + """Test posting a submission for a non-existing project""" + + def test_post_submissions_wrong_grading(self, client, session): + """Test posting a submission with a wrong grading""" + + def test_post_submissions_wrong_form(self, client, session): + """Test posting a submission with a wrong data form""" + + def test_post_submissions_wrong_files(self, client, session): + """Test posting a submission with no or wrong files""" + + def test_post_submissions_database_issue(self, client, session): + """Test posting the submissions with a faulty database""" + + def test_post_submissions_correct(self, client, session): + """Test posting a submission""" + + ### GET SUBMISSION ### + def test_get_submission_wrong_id(self, client, session): + """Test getting a submission for a non-existing submission id""" + + def test_get_submission_database_issue(self, client, session): + """Test getting a submission with a faulty database""" + + def test_get_submission_correct(self, client, session): + """Test getting a submission""" + + ### PATCH SUBMISSION ### + def test_patch_submission_wrong_id(self, client, session): + """Test patching a submission for a non-existing submission id""" + + def test_patch_submission_wrong_grading(self, client, session): + """Test patching a submission with a wrong grading""" + + def test_patch_submission_wrong_form(self, client, session): + """Test patching a submisson with a wrong data form""" + + def test_patch_submission_database_issue(self, client, session): + """Test patching a submission with a faulty database""" + + def test_patch_submission_correct(self, client, session): + """Test patching a submission""" + + ### DELETE SUBMISSION ### + def test_delete_submission_wrong_id(self, client, session): + """Test deleting a submission for a non-existing submission id""" + + def test_delete_submission_database_issue(self, client, session): + """Test deleting a submission with a faulty database""" + + def test_delete_submission_correct(self, client, session): + """Test deleting a submission""" From cfe6dd4ad59ea21f103edc2410e78be673043f0a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:03:04 +0100 Subject: [PATCH 011/144] #15 - Changing Exception to exc.SQLAlchemyError --- backend/project/endpoints/submissions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index f6c4e4d8..eb0c7bd1 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -3,6 +3,7 @@ from datetime import datetime from flask import Blueprint, request from flask_restful import Resource +from sqlalchemy import exc from project.database import db from project.models.submissions import Submissions as m_submissions from project.models.projects import Projects as m_projects @@ -54,7 +55,7 @@ def get(self, uid: str, pid: int) -> dict[str, any]: submissions = session.query(m_submissions).filter_by(uid=uid, project_id=pid).all() submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] return {"submissions": submissions_urls} - except Exception: + except exc.SQLAlchemyError: return {"message": f"An error occurred while fetching the submissions " f"from user {uid} for project {pid}"}, 500 @@ -121,7 +122,7 @@ def post(self, uid: str, pid: int) -> dict[str, any]: session.add(submission) session.commit() return {"submission": f"/submissions/{submission.submission_id}"}, 201 - except Exception: + except exc.SQLAlchemyError: session.rollback() return {"message": f"An error occurred while creating a new submission " f"for user {uid} in project {pid}"}, 500 @@ -169,7 +170,7 @@ def get(self, sid: int) -> dict[str, any]: "submission_path": submission.submission_path, "submission_status": submission.submission_status } - except Exception: + except exc.SQLAlchemyError: return {"message": f"An error occurred while fetching submission {sid}"}, 500 def patch(self, sid:int) -> dict[str, any]: @@ -215,7 +216,7 @@ def patch(self, sid:int) -> dict[str, any]: # Save the submission session.commit() return {"message": f"Submission {sid} updated"} - except Exception: + except exc.SQLAlchemyError: session.rollback() return {"message": f"An error occurred while patching submission {sid}"}, 500 @@ -254,7 +255,7 @@ def delete(self, sid: int) -> dict[str, any]: session.delete(submission) session.commit() return {"message": f"Submission {sid} deleted"} - except Exception: + except exc.SQLAlchemyError: db.session.rollback() return {"message": f"An error occurred while deleting submission {sid}"}, 500 From 450c6614f2d8b5315392ed3f690417bdcf02becf Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:07:59 +0100 Subject: [PATCH 012/144] #15 - Removing some commented out code --- backend/project/endpoints/submissions.py | 72 +----------------------- 1 file changed, 1 insertion(+), 71 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index eb0c7bd1..a8e9bb61 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -25,22 +25,8 @@ def get(self, uid: str, pid: int) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Check user user = session.get(m_users, uid) if user is None: @@ -70,22 +56,8 @@ def post(self, uid: str, pid: int) -> dict[str, any]: dict[str, any]: The URL to the submission """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if uid_operator != uid: - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - submission = m_submissions() # User @@ -113,7 +85,7 @@ def post(self, uid: str, pid: int) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - # get the files and store them + # Get the files, store them, test them ... submission.submission_path = "/tbd" # Submission status @@ -140,22 +112,8 @@ def get(self, sid: int) -> dict[str, any]: dict[str, any]: The submission """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Get the submission submission = session.get(m_submissions, sid) if submission is None: @@ -183,22 +141,8 @@ def patch(self, sid:int) -> dict[str, any]: dict[str, any]: A message """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not operator.is_teacher: - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Get the submission submission = session.get(m_submissions, sid) if submission is None: @@ -230,22 +174,8 @@ def delete(self, sid: int) -> dict[str, any]: dict[str, any]: A message """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not operator.is_admin: - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Check if the submission exists submission = session.get(m_submissions, sid) if submission is None: From 5ff71c08500e433e1df20b0629e0dd6d13fa5a71 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 1 Mar 2024 10:38:38 +0100 Subject: [PATCH 013/144] #15 - Updating the endpoint to use query parameters --- .../endpoints/index/OpenAPI_Object.json | 65 ++++++++---------- backend/project/endpoints/submissions.py | 67 +++++++++---------- 2 files changed, 57 insertions(+), 75 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 7b855bbd..23d9df3d 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -44,7 +44,25 @@ "paths": { "/submissions/{uid}/{pid}": { "get": { - "summary": "Get all submissions from a user for a project", + "summary": "Get the submissions", + "parameters": [ + { + "name": "uid", + "in": "query", + "description": "User ID", + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project ID", + "schema": { + "type": "int" + } + } + ], "responses": { "200": { "description": "A list of submission URLs", @@ -64,21 +82,6 @@ } } }, - "404": { - "description": "A 'not found' message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - }, "500": { "description": "An error message", "content": { @@ -99,12 +102,18 @@ "post": { "summary": "Post a new submission to a project", "requestBody": { - "description": "Grading", + "description": "Form data", "content": { "application/json": { "schema": { "type": "object", "properties": { + "uid": { + "type": "string" + }, + "project_id": { + "type": "int" + }, "grading": { "type": "int", "minimum": 0, @@ -177,27 +186,7 @@ } } } - }, - "parameters": [ - { - "name": "uid", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "pid", - "in": "path", - "description": "Project ID", - "required": true, - "schema": { - "type": "int" - } - } - ] + } }, "/submissions/{sid}": { "get": { diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index a8e9bb61..cf4b4682 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -1,6 +1,7 @@ """Submission API endpoint""" from datetime import datetime +from os import getenv from flask import Blueprint, request from flask_restful import Resource from sqlalchemy import exc @@ -14,44 +15,39 @@ class Submissions(Resource): """API endpoint for the submissions""" - def get(self, uid: str, pid: int) -> dict[str, any]: + def get(self) -> dict[str, any]: """Get all the submissions from a user for a project - Args: - uid (str): User ID - pid (int): Project ID - Returns: dict[str, any]: The list of submission URLs """ try: with db.session() as session: - # Check user - user = session.get(m_users, uid) - if user is None: - return {"message": f"User {uid} not found"}, 404 + query = session.query(m_submissions) + + # Filter by uid + uid = request.args.get("uid") + if (uid is not None) and (session.get(m_users, uid) is not None): + query.filter_by(uid=uid) - # Check project - project = session.get(m_projects, pid) - if project is None: - return {"message": f"Project {pid} not found"}, 404 + # Filter by project_id + project_id = request.args.get("project_id") + if (project_id is not None) and (session.get(m_projects, project_id) is not None): + query.filter_by(project_id=project_id) # Get the submissions - submissions = session.query(m_submissions).filter_by(uid=uid, project_id=pid).all() - submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] + submissions = query.all() + submissions_urls = [ + f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in submissions + ] return {"submissions": submissions_urls} except exc.SQLAlchemyError: - return {"message": f"An error occurred while fetching the submissions " - f"from user {uid} for project {pid}"}, 500 + return {"message": "An error occurred while fetching the submissions"}, 500 - def post(self, uid: str, pid: int) -> dict[str, any]: + def post(self) -> dict[str, any]: """Post a new submission to a project - Args: - uid (str): User ID - pid (int): Project ID - Returns: dict[str, any]: The URL to the submission """ @@ -61,16 +57,16 @@ def post(self, uid: str, pid: int) -> dict[str, any]: submission = m_submissions() # User - user = session.get(m_users, uid) - if user is None: + uid = request.form.get("uid") + if (uid is None) or (session.get(m_users, uid) is None): return {"message": f"User {uid} not found"}, 404 submission.uid = uid # Project - project = session.get(m_projects, pid) - if project is None: - return {"message": f"Project {pid} not found"}, 404 - submission.project_id = pid + project_id = request.form.get("project_id") + if (project_id is None) or (session.get(m_projects, project_id)): + return {"message": f"Project {project_id} not found"}, 404 + submission.project_id = project_id # Grading if "grading" in request.form: @@ -93,11 +89,12 @@ def post(self, uid: str, pid: int) -> dict[str, any]: session.add(submission) session.commit() - return {"submission": f"/submissions/{submission.submission_id}"}, 201 + return { + "submission": f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" + }, 201 except exc.SQLAlchemyError: session.rollback() - return {"message": f"An error occurred while creating a new submission " - f"for user {uid} in project {pid}"}, 500 + return {"message": "An error occurred while creating a new submission "}, 500 class Submission(Resource): """API endpoint for the submission""" @@ -189,9 +186,5 @@ def delete(self, sid: int) -> dict[str, any]: db.session.rollback() return {"message": f"An error occurred while deleting submission {sid}"}, 500 -submissions_bp.add_url_rule( - "/submissions//", - view_func=Submissions.as_view("submissions")) -submissions_bp.add_url_rule( - "/submissions/", - view_func=Submission.as_view("submission")) +submissions_bp.add_url_rule("/submissions", view_func=Submissions.as_view("submissions")) +submissions_bp.add_url_rule("/submissions/", view_func=Submission.as_view("submission")) From 010fc890411687e7cc7133afe6cdd0de558de1af Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 1 Mar 2024 16:09:10 +0100 Subject: [PATCH 014/144] #15 - Tests for get submissions --- backend/project/endpoints/submissions.py | 29 +++++++---- backend/tests/endpoints/conftest.py | 23 +++++---- backend/tests/endpoints/submissions_test.py | 53 ++++++++++++++++++--- 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index cf4b4682..188e7def 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -22,28 +22,39 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ + data = {} try: with db.session() as session: query = session.query(m_submissions) # Filter by uid uid = request.args.get("uid") - if (uid is not None) and (session.get(m_users, uid) is not None): - query.filter_by(uid=uid) + if uid is not None: + if session.get(m_users, uid) is not None: + query = query.filter_by(uid=uid) + else: + data["message"] = f"Invalid user (uid={uid})" + return data, 400 # Filter by project_id project_id = request.args.get("project_id") - if (project_id is not None) and (session.get(m_projects, project_id) is not None): - query.filter_by(project_id=project_id) + if project_id is not None: + if session.get(m_projects, project_id) is not None: + query = query.filter_by(project_id=project_id) + else: + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 # Get the submissions - submissions = query.all() - submissions_urls = [ - f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in submissions + data["message"] = "Successfully fetched the submissions" + data["submissions"] = [ + f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in query.all() ] - return {"submissions": submissions_urls} + return data, 200 + except exc.SQLAlchemyError: - return {"message": "An error occurred while fetching the submissions"}, 500 + data["message"] = "An error occurred while fetching the submissions" + return data, 500 def post(self) -> dict[str, any]: """Post a new submission to a project diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 88bdb75a..d61dd089 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,29 +1,34 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project.database import db, get_database_uri +engine = create_engine(get_database_uri()) +Session = sessionmaker(bind=engine) @pytest.fixture def session(): """Create a database session for the tests""" # Create all tables - db.create_all() + db.metadata.create_all(engine) + + session = Session() # Populate the database - db.session.commit() + session.commit() # Tests can now use a populated database - yield db.session + yield session # Rollback - db.session.rollback() + session.rollback() + session.close() # Remove all tables for table in reversed(db.metadata.sorted_tables): - db.session.execute(table.delete()) - db.session.commit() - - db.session.close() + session.execute(table.delete()) + session.commit() @pytest.fixture def app(): @@ -31,7 +36,9 @@ def app(): Returns: Flask -- A Flask application instance """ + engine = create_engine(get_database_uri()) app = create_app_with_db(get_database_uri()) + db.metadata.create_all(engine) yield app @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index e5f3e856..5971a60e 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -1,20 +1,61 @@ """Test the submissions API endpoint""" +from os import getenv + class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client, session): + def test_get_submissions_wrong_user(self, client): """Test getting submissions for a non-existing user""" + response = client.get("/submissions?uid=unknown") + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid user (uid=unknown)" - def test_get_submissions_wrong_project(self, client, session): + def test_get_submissions_wrong_project(self, client): """Test getting submissions for a non-existing project""" + response = client.get("/submissions?project_id=-1") + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=-1)" - def test_get_submissions_database_issue(self, client, session): - """Test getting the submissions with a faulty database""" - - def test_get_submissions_correct(self, client, session): + def test_get_submissions_all(self, client): """Test getting the submissions""" + response = client.get("/submissions") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1", + f"{getenv('HOSTNAME')}/submissions/2" + ] + + def test_get_submissions_user(self, client): + """Test getting the submissions given a specific user""" + response = client.get("/submissions?uid=user4") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1" + ] + + def test_get_submissions_project(self, client): + """Test getting the submissions given a specific project""" + response = client.get("/submissions?project_id=1") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1" + ] + + def test_get_submissions_user_project(self, client): + """Test getting the submissions given a specific user and project""" + response = client.get("/submissions?uid=user4&project_id=1") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1" + ] ### POST SUBMISSIONS ### def test_post_submissions_wrong_user(self, client, session): From 80e5a242367724d795ac22d8cdb5bfa295ca2f02 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 1 Mar 2024 16:16:54 +0100 Subject: [PATCH 015/144] #15 - Forget to update the OpenAPI document --- .../endpoints/index/OpenAPI_Object.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 23d9df3d..78d4fc80 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -71,6 +71,9 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "submissions": { "type": "array", "items": { @@ -82,6 +85,21 @@ } } }, + "400": { + "description": "An invalid data message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, "500": { "description": "An error message", "content": { From 4290e6a2d9c8975e664ca56c187a7c367d052952 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:25:26 +0100 Subject: [PATCH 016/144] #15 - Test for post method and fixtures for populating the database --- .../endpoints/index/OpenAPI_Object.json | 26 +--- backend/project/endpoints/submissions.py | 37 +++-- backend/pylintrc | 1 + backend/tests/endpoints/conftest.py | 140 ++++++++++++++++-- backend/tests/endpoints/submissions_test.py | 116 +++++++++++---- 5 files changed, 242 insertions(+), 78 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 78d4fc80..4729c11b 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -127,10 +127,12 @@ "type": "object", "properties": { "uid": { - "type": "string" + "type": "string", + "required": true }, "project_id": { - "type": "int" + "type": "int", + "required": true }, "grading": { "type": "int", @@ -150,6 +152,9 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "submission": { "type": "string" } @@ -159,22 +164,7 @@ } }, "400": { - "description": "A 'bad data field' message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "A 'not found' message", + "description": "An invalid data message", "content": { "application/json": { "schema": { diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 188e7def..ff504221 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -63,6 +63,7 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ + data = {} try: with db.session() as session: submission = m_submissions() @@ -70,22 +71,29 @@ def post(self) -> dict[str, any]: # User uid = request.form.get("uid") if (uid is None) or (session.get(m_users, uid) is None): - return {"message": f"User {uid} not found"}, 404 + if uid is None: + data["message"] = "The uid data field is required" + else: + data["message"] = f"Invalid user (uid={uid})" + return data, 400 submission.uid = uid # Project project_id = request.form.get("project_id") - if (project_id is None) or (session.get(m_projects, project_id)): - return {"message": f"Project {project_id} not found"}, 404 + if (project_id is None) or (session.get(m_projects, project_id) is None): + if project_id is None: + data["message"] = "The project_id data field is required" + else: + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 submission.project_id = project_id # Grading - if "grading" in request.form: - grading = request.form["grading"] - if grading < 0 or grading > 20: - return { - "message": "The submission must have a 'grading' in between 0-20" - }, 400 + grading = int(request.form.get("grading")) + if grading is not None: + if not 0 <= grading <= 20: + data["message"] = "Invalid grading (range=0-20)" + return data, 400 submission.grading = grading # Submission time @@ -100,12 +108,15 @@ def post(self) -> dict[str, any]: session.add(submission) session.commit() - return { - "submission": f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" - }, 201 + + data["message"] = "Successfully fetched the submissions" + data["submission"] = f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" + return data, 201 + except exc.SQLAlchemyError: session.rollback() - return {"message": "An error occurred while creating a new submission "}, 500 + data["message"] = "An error occurred while creating a new submission" + return data, 500 class Submission(Resource): """API endpoint for the submission""" diff --git a/backend/pylintrc b/backend/pylintrc index 86b66862..0770aea9 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -3,6 +3,7 @@ init-hook='import sys; sys.path.append(".")' [test-files:*_test.py] disable= + W0613, # Unused argument (pytest uses it) W0621, # Redefining name %r from outer scope (line %s) R0904, # Too many public methods (too many unit tests essentially) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index d61dd089..81dfc103 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,34 +1,144 @@ """ Configuration for pytest, Flask, and the test client.""" + +from datetime import datetime import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project.database import db, get_database_uri +from project.models.users import Users as m_users +from project.models.courses import Courses as m_courses +from project.models.course_relations import CourseAdmins as m_course_admins +from project.models.course_relations import CourseStudents as m_course_students +from project.models.projects import Projects as m_projects +from project.models.submissions import Submissions as m_submissions + +@pytest.fixture +def users(): + """Return a list of users to populate the database""" + return [ + m_users(uid="brinkmann", is_admin=True, is_teacher=True), + m_users(uid="laermans", is_admin=True, is_teacher=True), + m_users(uid="student01", is_admin=False, is_teacher=False), + m_users(uid="student02", is_admin=False, is_teacher=False) + ] + +@pytest.fixture +def courses(): + """Return a list of courses to populate the database""" + return [ + m_courses(course_id=1, name="AD3", teacher="brinkmann"), + m_courses(course_id=2, name="RAF", teacher="laermans"), + ] + +@pytest.fixture +def course_relations(): + """Returns a list of course relations to populate the database""" + return [ + m_course_admins(course_id=1, uid="brinkmann"), + m_course_students(course_id=1, uid="student01"), + m_course_students(course_id=1, uid="student02"), + m_course_admins(course_id=2, uid="laermans"), + m_course_students(course_id=2, uid="student02") + ] + +@pytest.fixture +def projects(): + """Return a list of projects to populate the database""" + return [ + m_projects( + project_id=1, + title="B+ Trees", + descriptions="Implement B+ trees", + assignment_file="assignement.pdf", + deadline=datetime(2024,3,15,13,0,0), + course_id=1, + visible_for_students=True, + archieved=False, + test_path="/tests", + script_name="script.sh", + regex_expressions=["*"] + ), + m_projects( + project_id=2, + title="Predicaten", + descriptions="Predicaten project", + assignment_file="assignment.pdf", + deadline=datetime(2023,3,15,13,0,0), + course_id=2, + visible_for_students=False, + archieved=True, + test_path="/tests", + script_name="script.sh", + regex_expressions=["*"] + ) + ] + +@pytest.fixture +def submissions(): + """Return a list of submissions to populate the database""" + return [ + m_submissions( + submission_id=1, + uid="student01", + project_id=1, + grading=16, + submission_time=datetime(2024,3,14,12,0,0), + submission_path="/submissions/1", + submission_status=True + ), + m_submissions( + submission_id=2, + uid="student02", + project_id=1, + submission_time=datetime(2024,3,14,23,59,59), + submission_path="/submissions/2", + submission_status=False + ), + m_submissions( + submission_id=3, + uid="student02", + project_id=2, + grading=15, + submission_time=datetime(2023,3,5,10,0,0), + submission_path="/submissions/3", + submission_status=True + ) + ] engine = create_engine(get_database_uri()) Session = sessionmaker(bind=engine) @pytest.fixture -def session(): +def session(users,courses,course_relations,projects,submissions): """Create a database session for the tests""" - # Create all tables + # Create all tables and get a session db.metadata.create_all(engine) - session = Session() - # Populate the database - session.commit() - - # Tests can now use a populated database - yield session + try: + # Populate the database + session.add_all(users) + session.commit() + session.add_all(courses) + session.commit() + session.add_all(course_relations) + session.commit() + session.add_all(projects) + session.commit() + session.add_all(submissions) + session.commit() - # Rollback - session.rollback() - session.close() + # Tests can now use a populated database + yield session + finally: + # Rollback + session.rollback() + session.close() - # Remove all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() + # Remove all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() @pytest.fixture def app(): diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5971a60e..e3b45b1b 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -1,56 +1,61 @@ """Test the submissions API endpoint""" from os import getenv +from flask.testing import FlaskClient +from sqlalchemy.orm import Session +from project.models.submissions import Submissions as m_submissions class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client): + def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing user""" response = client.get("/submissions?uid=unknown") data = response.json assert response.status_code == 400 assert data["message"] == "Invalid user (uid=unknown)" - def test_get_submissions_wrong_project(self, client): + def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") data = response.json assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" - def test_get_submissions_all(self, client): + def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 assert data["submissions"] == [ f"{getenv('HOSTNAME')}/submissions/1", - f"{getenv('HOSTNAME')}/submissions/2" + f"{getenv('HOSTNAME')}/submissions/2", + f"{getenv('HOSTNAME')}/submissions/3" ] - def test_get_submissions_user(self, client): + def test_get_submissions_user(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user""" - response = client.get("/submissions?uid=user4") + response = client.get("/submissions?uid=student01") data = response.json assert response.status_code == 200 assert data["submissions"] == [ f"{getenv('HOSTNAME')}/submissions/1" ] - def test_get_submissions_project(self, client): + def test_get_submissions_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific project""" response = client.get("/submissions?project_id=1") data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1" + f"{getenv('HOSTNAME')}/submissions/1", + f"{getenv('HOSTNAME')}/submissions/2" ] - def test_get_submissions_user_project(self, client): + def test_get_submissions_user_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user and project""" - response = client.get("/submissions?uid=user4&project_id=1") + response = client.get("/submissions?uid=student01&project_id=1") data = response.json assert response.status_code == 200 assert data["submissions"] == [ @@ -58,59 +63,106 @@ def test_get_submissions_user_project(self, client): ] ### POST SUBMISSIONS ### - def test_post_submissions_wrong_user(self, client, session): + def test_post_submissions_no_user(self, client: FlaskClient, session: Session): + """Test posting a submission without specifying a user""" + response = client.post("/submissions", data={ + "project_id": 1 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "The uid data field is required" + + def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing user""" + response = client.post("/submissions", data={ + "uid": "unknown", + "project_id": 1 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid user (uid=unknown)" - def test_post_submissions_wrong_project(self, client, session): + def test_post_submissions_no_project(self, client: FlaskClient, session: Session): + """Test posting a submission without specifying a project""" + response = client.post("/submissions", data={ + "uid": "student01" + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "The project_id data field is required" + + def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": -1 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=-1)" - def test_post_submissions_wrong_grading(self, client, session): + def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "grading": 80 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (range=0-20)" - def test_post_submissions_wrong_form(self, client, session): - """Test posting a submission with a wrong data form""" - - def test_post_submissions_wrong_files(self, client, session): + def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): """Test posting a submission with no or wrong files""" - def test_post_submissions_database_issue(self, client, session): - """Test posting the submissions with a faulty database""" - - def test_post_submissions_correct(self, client, session): + def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "grading": 16 + }) + data = response.json + assert response.status_code == 201 + assert data["message"] == "Successfully fetched the submissions" + + submission = session.query(m_submissions).filter_by( + uid="student01", project_id=1, grading=16 + ).first() + assert submission is not None ### GET SUBMISSION ### - def test_get_submission_wrong_id(self, client, session): + def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - def test_get_submission_database_issue(self, client, session): + def test_get_submission_database_issue(self, client: FlaskClient, session: Session): """Test getting a submission with a faulty database""" - def test_get_submission_correct(self, client, session): + def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" ### PATCH SUBMISSION ### - def test_patch_submission_wrong_id(self, client, session): + def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - def test_patch_submission_wrong_grading(self, client, session): + def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" - def test_patch_submission_wrong_form(self, client, session): + def test_patch_submission_wrong_form(self, client: FlaskClient, session: Session): """Test patching a submisson with a wrong data form""" - def test_patch_submission_database_issue(self, client, session): + def test_patch_submission_database_issue(self, client: FlaskClient, session: Session): """Test patching a submission with a faulty database""" - def test_patch_submission_correct(self, client, session): + def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" ### DELETE SUBMISSION ### - def test_delete_submission_wrong_id(self, client, session): + def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - def test_delete_submission_database_issue(self, client, session): + def test_delete_submission_database_issue(self, client: FlaskClient, session: Session): """Test deleting a submission with a faulty database""" - def test_delete_submission_correct(self, client, session): + def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" From 3edfcafa9c3088b265afb6d82b5ec48b67f3f663 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:56:48 +0100 Subject: [PATCH 017/144] #15 - Tests for the get method --- .../endpoints/index/OpenAPI_Object.json | 45 +++++++++-------- backend/project/endpoints/submissions.py | 48 +++++++++++-------- backend/tests/endpoints/submissions_test.py | 19 ++++++-- 3 files changed, 69 insertions(+), 43 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 4729c11b..488be8c2 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -196,7 +196,7 @@ } } }, - "/submissions/{sid}": { + "/submissions/{submission_id}": { "get": { "summary": "Get the submission", "responses": { @@ -207,26 +207,31 @@ "schema": { "type": "object", "properties": { - "submission_id": { - "type": "int" - }, - "uid": { - "type": "string" - }, - "project_id": { - "type": "int" - }, - "grading": { - "type": "int" - }, - "submission_time": { - "type": "string" - }, - "submission_path": { + "message": { "type": "string" }, - "submission_status": { - "type": "int" + "submission": { + "submission_id": { + "type": "int" + }, + "uid": { + "type": "string" + }, + "project_id": { + "type": "int" + }, + "grading": { + "type": "int" + }, + "submission_time": { + "type": "string" + }, + "submission_path": { + "type": "string" + }, + "submission_status": { + "type": "int" + } } } } @@ -399,7 +404,7 @@ }, "parameters": [ { - "name": "sid", + "name": "submission_id", "in": "path", "description": "Submission ID", "required": true, diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index ff504221..e40192eb 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -121,24 +121,26 @@ def post(self) -> dict[str, any]: class Submission(Resource): """API endpoint for the submission""" - def get(self, sid: int) -> dict[str, any]: + def get(self, submission_id: int) -> dict[str, any]: """Get the submission given an submission ID Args: - sid (int): Submission ID + submission_id (int): Submission ID Returns: dict[str, any]: The submission """ + data = {} try: with db.session() as session: - # Get the submission - submission = session.get(m_submissions, sid) + submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {sid} not found"}, 404 + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 - return { + data["message"] = "Successfully fetched the submission" + data["submission"] = { "submission_id": submission.submission_id, "uid": submission.uid, "project_id": submission.project_id, @@ -147,14 +149,17 @@ def get(self, sid: int) -> dict[str, any]: "submission_path": submission.submission_path, "submission_status": submission.submission_status } + return data, 200 except exc.SQLAlchemyError: - return {"message": f"An error occurred while fetching submission {sid}"}, 500 + data["message"] = \ + f"An error occurred while fetching the submission (submission_id={submission_id})" + return data, 500 - def patch(self, sid:int) -> dict[str, any]: + def patch(self, submission_id:int) -> dict[str, any]: """Update some fields of a submission given a submission ID Args: - sid (int): Submission ID + submission_id (int): Submission ID Returns: dict[str, any]: A message @@ -163,9 +168,9 @@ def patch(self, sid:int) -> dict[str, any]: try: with db.session() as session: # Get the submission - submission = session.get(m_submissions, sid) + submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {sid} not found"}, 404 + return {"message": f"Submission {submission_id} not found"}, 404 # Update the grading field (its the only field that a teacher can update) if "grading" in request.form: @@ -178,16 +183,16 @@ def patch(self, sid:int) -> dict[str, any]: # Save the submission session.commit() - return {"message": f"Submission {sid} updated"} + return {"message": f"Submission {submission_id} updated"} except exc.SQLAlchemyError: session.rollback() - return {"message": f"An error occurred while patching submission {sid}"}, 500 + return {"message": f"An error occurred while patching submission {submission_id}"}, 500 - def delete(self, sid: int) -> dict[str, any]: + def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given an submission ID Args: - sid (int): Submission ID + submission_id (int): Submission ID Returns: dict[str, any]: A message @@ -196,17 +201,20 @@ def delete(self, sid: int) -> dict[str, any]: try: with db.session() as session: # Check if the submission exists - submission = session.get(m_submissions, sid) + submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {sid} not found"}, 404 + return {"message": f"Submission {submission_id} not found"}, 404 # Delete the submission session.delete(submission) session.commit() - return {"message": f"Submission {sid} deleted"} + return {"message": f"Submission {submission_id} deleted"} except exc.SQLAlchemyError: db.session.rollback() - return {"message": f"An error occurred while deleting submission {sid}"}, 500 + return {"message": f"An error occurred while deleting submission {submission_id}"}, 500 submissions_bp.add_url_rule("/submissions", view_func=Submissions.as_view("submissions")) -submissions_bp.add_url_rule("/submissions/", view_func=Submission.as_view("submission")) +submissions_bp.add_url_rule( + "/submissions/", + view_func=Submission.as_view("submission") +) diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index e3b45b1b..75769fb3 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -134,12 +134,25 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - - def test_get_submission_database_issue(self, client: FlaskClient, session: Session): - """Test getting a submission with a faulty database""" + response = client.get("/submissions/100") + data = response.json + assert response.status_code == 404 + assert data["message"] == "Submission (submission_id=100) not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" + response = client.get("/submissions/1") + data = response.json + assert response.status_code == 200 + assert data["submission"] == { + "submission_id": 1, + "uid": "student01", + "project_id": 1, + "grading": 16, + "submission_time": "Thu, 14 Mar 2024 11:00:00 GMT", + "submission_path": "/submissions/1", + "submission_status": True + } ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): From 637b5b6c4f2b4643a30e19c3f514e3ed281a4ae6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 16:12:49 +0100 Subject: [PATCH 018/144] #15 - Tests for patch method --- .../endpoints/index/OpenAPI_Object.json | 8 +++---- backend/project/endpoints/submissions.py | 24 +++++++++++-------- backend/tests/endpoints/submissions_test.py | 23 ++++++++++++------ 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 488be8c2..dfce23f6 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -86,7 +86,7 @@ } }, "400": { - "description": "An invalid data message", + "description": "An 'invalid data' message", "content": { "application/json": { "schema": { @@ -164,7 +164,7 @@ } }, "400": { - "description": "An invalid data message", + "description": "An 'invalid data' message", "content": { "application/json": { "schema": { @@ -271,7 +271,7 @@ } }, "patch": { - "summary": "Update the submission", + "summary": "Patch the submission", "requestBody": { "description": "The submission data", "content": { @@ -306,7 +306,7 @@ } }, "400": { - "description": "A 'bad data field' message", + "description": "An 'invalid data' message", "content": { "application/json": { "schema": { diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index e40192eb..46371833 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -92,7 +92,7 @@ def post(self) -> dict[str, any]: grading = int(request.form.get("grading")) if grading is not None: if not 0 <= grading <= 20: - data["message"] = "Invalid grading (range=0-20)" + data["message"] = "Invalid grading (grading=0-20)" return data, 400 submission.grading = grading @@ -165,28 +165,32 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ + data = {} try: with db.session() as session: # Get the submission submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {submission_id} not found"}, 404 + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 - # Update the grading field (its the only field that a teacher can update) + # Update the grading field if "grading" in request.form: - grading = request.form["grading"] - if grading < 0 or grading > 20: - return { - "message": "The submission must have a 'grading' in between 0-20" - }, 400 + grading = int(request.form["grading"]) + if not 0 <= grading <= 20: + data["message"] = "Invalid grading (grading=0-20)" + return data, 400 submission.grading = grading # Save the submission session.commit() - return {"message": f"Submission {submission_id} updated"} + data["message"] = f"Successfully patched submission (submission_id={submission_id})" + return data, 200 except exc.SQLAlchemyError: session.rollback() - return {"message": f"An error occurred while patching submission {submission_id}"}, 500 + data["message"] = \ + f"An error occurred while patching submission (submission_id={submission_id})" + return data, 500 def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given an submission ID diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 75769fb3..4365026f 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -110,7 +110,7 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid grading (range=0-20)" + assert data["message"] == "Invalid grading (grading=0-20)" def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): """Test posting a submission with no or wrong files""" @@ -157,18 +157,27 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" + response = client.patch("/submissions/100", data={"grading": 20}) + data = response.json + assert response.status_code == 404 + assert data["message"] == "Submission (submission_id=100) not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" - - def test_patch_submission_wrong_form(self, client: FlaskClient, session: Session): - """Test patching a submisson with a wrong data form""" - - def test_patch_submission_database_issue(self, client: FlaskClient, session: Session): - """Test patching a submission with a faulty database""" + response = client.patch("/submissions/2", data={"grading": 100}) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (grading=0-20)" def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" + response = client.patch("/submissions/2", data={"grading": 20}) + data = response.json + assert response.status_code == 200 + assert data["message"] == "Successfully patched submission (submission_id=2)" + + submission = session.get(m_submissions, 2) + assert submission.grading == 20 ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): From 1ccc3a0d7cc07dd52506caa09e6c8173eb4b1997 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 16:57:05 +0100 Subject: [PATCH 019/144] #15 - Tests for delete method --- backend/project/endpoints/submissions.py | 14 +++++++++----- backend/tests/endpoints/submissions_test.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 46371833..389f1b22 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -184,7 +184,7 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() - data["message"] = f"Successfully patched submission (submission_id={submission_id})" + data["message"] = f"Submission (submission_id={submission_id}) patched" return data, 200 except exc.SQLAlchemyError: session.rollback() @@ -202,20 +202,24 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ + data = {} try: with db.session() as session: - # Check if the submission exists submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {submission_id} not found"}, 404 + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 # Delete the submission session.delete(submission) session.commit() - return {"message": f"Submission {submission_id} deleted"} + data["message"] = f"Submission (submission_id={submission_id}) deleted" + return data, 200 except exc.SQLAlchemyError: db.session.rollback() - return {"message": f"An error occurred while deleting submission {submission_id}"}, 500 + data["message"] = \ + f"An error occurred while deleting submission (submission_id={submission_id})" + return data, 500 submissions_bp.add_url_rule("/submissions", view_func=Submissions.as_view("submissions")) submissions_bp.add_url_rule( diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 4365026f..6141c9d2 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -174,7 +174,7 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): response = client.patch("/submissions/2", data={"grading": 20}) data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully patched submission (submission_id=2)" + assert data["message"] == "Submission (submission_id=2) patched" submission = session.get(m_submissions, 2) assert submission.grading == 20 @@ -182,9 +182,17 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - - def test_delete_submission_database_issue(self, client: FlaskClient, session: Session): - """Test deleting a submission with a faulty database""" + response = client.delete("submissions/100") + data = response.json + assert response.status_code == 404 + assert data["message"] == "Submission (submission_id=100) not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" + response = client.delete("submissions/1") + data = response.json + assert response.status_code == 200 + assert data["message"] == "Submission (submission_id=1) deleted" + + submission = session.get(m_submissions, 1) + assert submission is None From 65d84b525c08ea7a912a55e825e97f6f8efd6684 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 18:17:01 +0100 Subject: [PATCH 020/144] #15 - Testing for the correct type, now 400: bad request instead of 500: internal error --- backend/project/endpoints/submissions.py | 36 ++++++++++++--------- backend/tests/endpoints/submissions_test.py | 35 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 389f1b22..dba483a2 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -39,11 +39,10 @@ def get(self) -> dict[str, any]: # Filter by project_id project_id = request.args.get("project_id") if project_id is not None: - if session.get(m_projects, project_id) is not None: - query = query.filter_by(project_id=project_id) - else: + if not project_id.isdigit() or session.get(m_projects, int(project_id)) is None: data["message"] = f"Invalid project (project_id={project_id})" return data, 400 + query = query.filter_by(project_id=int(project_id)) # Get the submissions data["message"] = "Successfully fetched the submissions" @@ -80,21 +79,21 @@ def post(self) -> dict[str, any]: # Project project_id = request.form.get("project_id") - if (project_id is None) or (session.get(m_projects, project_id) is None): - if project_id is None: - data["message"] = "The project_id data field is required" - else: - data["message"] = f"Invalid project (project_id={project_id})" + if project_id is None: + data["message"] = "The project_id data field is required" return data, 400 - submission.project_id = project_id + if not project_id.isdigit() or session.get(m_projects, int(project_id)) is None: + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 + submission.project_id = int(project_id) # Grading - grading = int(request.form.get("grading")) + grading = request.form.get("grading") if grading is not None: - if not 0 <= grading <= 20: + if not (grading.isdigit() and 0 <= int(grading) <= 20): data["message"] = "Invalid grading (grading=0-20)" return data, 400 - submission.grading = grading + submission.grading = int(grading) # Submission time submission.submission_time = datetime.now() @@ -150,6 +149,7 @@ def get(self, submission_id: int) -> dict[str, any]: "submission_status": submission.submission_status } return data, 200 + except exc.SQLAlchemyError: data["message"] = \ f"An error occurred while fetching the submission (submission_id={submission_id})" @@ -175,17 +175,19 @@ def patch(self, submission_id:int) -> dict[str, any]: return data, 404 # Update the grading field - if "grading" in request.form: - grading = int(request.form["grading"]) - if not 0 <= grading <= 20: + grading = request.form.get("grading") + if grading is not None: + if not (grading.isdigit() and 0 <= int(grading) <= 20): data["message"] = "Invalid grading (grading=0-20)" return data, 400 - submission.grading = grading + submission.grading = int(grading) # Save the submission session.commit() + data["message"] = f"Submission (submission_id={submission_id}) patched" return data, 200 + except exc.SQLAlchemyError: session.rollback() data["message"] = \ @@ -213,8 +215,10 @@ def delete(self, submission_id: int) -> dict[str, any]: # Delete the submission session.delete(submission) session.commit() + data["message"] = f"Submission (submission_id={submission_id}) deleted" return data, 200 + except exc.SQLAlchemyError: db.session.rollback() data["message"] = \ diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 6141c9d2..9a742b73 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -23,6 +23,13 @@ def test_get_submissions_wrong_project(self, client: FlaskClient, session: Sessi assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" + def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + """Test getting submissions for a non-existing project of the wrong type""" + response = client.get("/submissions?project_id=zero") + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=zero)" + def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") @@ -101,6 +108,16 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" + def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + """Test posting a submission for a non-existing project of the wrong type""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": "zero" + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=zero)" + def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" response = client.post("/submissions", data={ @@ -112,6 +129,17 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" + def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): + """Test posting a submission with a wrong grading type""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "grading": "zero" + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (grading=0-20)" + def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): """Test posting a submission with no or wrong files""" @@ -169,6 +197,13 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" + def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): + """Test patching a submission with a wrong grading type""" + response = client.patch("/submissions/2", data={"grading": "zero"}) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (grading=0-20)" + def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" response = client.patch("/submissions/2", data={"grading": 20}) From b6074731fdabdd88c779418af73e894b33ca78ba Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 19:44:33 +0100 Subject: [PATCH 021/144] #15 - Updating HOSTNAME to API_HOST and call getenv() once at the start of the file --- backend/project/database.py | 11 +++++------ backend/project/endpoints/submissions.py | 8 ++++++-- backend/tests/endpoints/submissions_test.py | 16 +++++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/backend/project/database.py b/backend/project/database.py index 1c879be5..776d7008 100644 --- a/backend/project/database.py +++ b/backend/project/database.py @@ -15,11 +15,10 @@ def get_database_uri() -> str: """ load_dotenv() uri = URL.create( - drivername=getenv("DB_DRIVER"), - username=getenv("DB_USER"), - password=getenv("DB_PASSWORD"), - host=getenv("DB_HOST"), - port=int(getenv("DB_PORT")), - database=getenv("DB_NAME") + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_NAME") ) return uri diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index dba483a2..3deb82fa 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,6 +2,7 @@ from datetime import datetime from os import getenv +from dotenv import load_dotenv from flask import Blueprint, request from flask_restful import Resource from sqlalchemy import exc @@ -10,6 +11,9 @@ from project.models.projects import Projects as m_projects from project.models.users import Users as m_users +load_dotenv() +API_HOST = getenv("API_HOST") + submissions_bp = Blueprint("submissions", __name__) class Submissions(Resource): @@ -47,7 +51,7 @@ def get(self) -> dict[str, any]: # Get the submissions data["message"] = "Successfully fetched the submissions" data["submissions"] = [ - f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in query.all() + f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() ] return data, 200 @@ -109,7 +113,7 @@ def post(self) -> dict[str, any]: session.commit() data["message"] = "Successfully fetched the submissions" - data["submission"] = f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" + data["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" return data, 201 except exc.SQLAlchemyError: diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 9a742b73..5bed1211 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -5,6 +5,8 @@ from sqlalchemy.orm import Session from project.models.submissions import Submissions as m_submissions +API_HOST = getenv("API_HOST") + class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" @@ -36,9 +38,9 @@ def test_get_submissions_all(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1", - f"{getenv('HOSTNAME')}/submissions/2", - f"{getenv('HOSTNAME')}/submissions/3" + f"{API_HOST}/submissions/1", + f"{API_HOST}/submissions/2", + f"{API_HOST}/submissions/3" ] def test_get_submissions_user(self, client: FlaskClient, session: Session): @@ -47,7 +49,7 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1" + f"{API_HOST}/submissions/1" ] def test_get_submissions_project(self, client: FlaskClient, session: Session): @@ -56,8 +58,8 @@ def test_get_submissions_project(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1", - f"{getenv('HOSTNAME')}/submissions/2" + f"{API_HOST}/submissions/1", + f"{API_HOST}/submissions/2" ] def test_get_submissions_user_project(self, client: FlaskClient, session: Session): @@ -66,7 +68,7 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1" + f"{API_HOST}/submissions/1" ] ### POST SUBMISSIONS ### From 2ada21d3fd6d010f4337c5dcc3289e5bb3d1b300 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 3 Mar 2024 11:31:18 +0100 Subject: [PATCH 022/144] #15 - Updating responses to a more unified way --- .../endpoints/index/OpenAPI_Object.json | 204 +++++++++++++++--- backend/project/endpoints/submissions.py | 53 +++-- backend/tests/endpoints/submissions_test.py | 78 +++++-- 3 files changed, 265 insertions(+), 70 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index dfce23f6..bf978c85 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -42,7 +42,7 @@ ] }, "paths": { - "/submissions/{uid}/{pid}": { + "/submissions": { "get": { "summary": "Get the submissions", "parameters": [ @@ -59,7 +59,7 @@ "in": "query", "description": "Project ID", "schema": { - "type": "int" + "type": "integer" } } ], @@ -71,13 +71,21 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, - "submissions": { - "type": "array", - "items": { - "type": "string" + "data": { + "type": "object", + "properties": { + "submissions": "array", + "items": { + "type": "string", + "format": "uri" + } } } } @@ -92,8 +100,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -107,8 +123,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -131,11 +155,11 @@ "required": true }, "project_id": { - "type": "int", + "type": "integer", "required": true }, "grading": { - "type": "int", + "type": "integer", "minimum": 0, "maximum": 20 } @@ -152,11 +176,21 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, - "submission": { - "type": "string" + "data": { + "type": "object", + "properties": { + "submission": { + "type": "string", + "format": "uri" + } + } } } } @@ -170,8 +204,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -185,8 +227,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -207,30 +257,46 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, - "submission": { - "submission_id": { - "type": "int" - }, - "uid": { - "type": "string" - }, - "project_id": { - "type": "int" - }, - "grading": { - "type": "int" - }, - "submission_time": { - "type": "string" - }, - "submission_path": { - "type": "string" - }, - "submission_status": { - "type": "int" + "data": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "submission_id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer", + "nullable": true + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" + } + } + } } } } @@ -245,8 +311,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -260,8 +334,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -280,7 +362,7 @@ "type": "object", "properties": { "grading": { - "type": "int", + "type": "integer", "minimum": 0, "maximum": 20 } @@ -297,8 +379,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -312,8 +402,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -327,8 +425,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -342,8 +448,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -362,8 +476,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -377,8 +499,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -392,8 +522,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -409,7 +547,7 @@ "description": "Submission ID", "required": true, "schema": { - "type": "int" + "type": "integer" } } ] diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 3deb82fa..937eff66 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -26,7 +26,11 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - data = {} + data = { + "url": f"{API_HOST}/submissions", + "message": "Successfully fetched the submissions", + "data": {} + } try: with db.session() as session: query = session.query(m_submissions) @@ -49,8 +53,7 @@ def get(self) -> dict[str, any]: query = query.filter_by(project_id=int(project_id)) # Get the submissions - data["message"] = "Successfully fetched the submissions" - data["submissions"] = [ + data["data"]["submissions"] = [ f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() ] return data, 200 @@ -66,7 +69,11 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ - data = {} + data = { + "url": f"{API_HOST}/submissions", + "message": "Successfully fetched the submissions", + "data": {} + } try: with db.session() as session: submission = m_submissions() @@ -112,8 +119,7 @@ def post(self) -> dict[str, any]: session.add(submission) session.commit() - data["message"] = "Successfully fetched the submissions" - data["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["data"]["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" return data, 201 except exc.SQLAlchemyError: @@ -134,7 +140,11 @@ def get(self, submission_id: int) -> dict[str, any]: dict[str, any]: The submission """ - data = {} + data = { + "url": f"{API_HOST}/submissions/{submission_id}", + "message": "Successfully fetched the submission", + "data": {} + } try: with db.session() as session: submission = session.get(m_submissions, submission_id) @@ -142,15 +152,14 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 - data["message"] = "Successfully fetched the submission" - data["submission"] = { - "submission_id": submission.submission_id, - "uid": submission.uid, - "project_id": submission.project_id, + data["data"]["submission"] = { + "id": submission.submission_id, + "user": f"{API_HOST}/users/{submission.uid}", + "project": f"{API_HOST}/projects/{submission.project_id}", "grading": submission.grading, - "submission_time": submission.submission_time, - "submission_path": submission.submission_path, - "submission_status": submission.submission_status + "time": submission.submission_time, + "path": submission.submission_path, + "status": submission.submission_status } return data, 200 @@ -169,7 +178,11 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": f"{API_HOST}/submissions/{submission_id}", + "message": f"Submission (submission_id={submission_id}) patched", + "data": {} + } try: with db.session() as session: # Get the submission @@ -189,7 +202,6 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() - data["message"] = f"Submission (submission_id={submission_id}) patched" return data, 200 except exc.SQLAlchemyError: @@ -208,7 +220,11 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": f"{API_HOST}/submissions/{submission_id}", + "message": f"Submission (submission_id={submission_id}) deleted", + "data": {} + } try: with db.session() as session: submission = session.get(m_submissions, submission_id) @@ -220,7 +236,6 @@ def delete(self, submission_id: int) -> dict[str, any]: session.delete(submission) session.commit() - data["message"] = f"Submission (submission_id={submission_id}) deleted" return data, 200 except exc.SQLAlchemyError: diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5bed1211..869c17c4 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -16,28 +16,36 @@ def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session) response = client.get("/submissions?uid=unknown") data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" + assert data["data"] == {} def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" + assert data["data"] == {} def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project of the wrong type""" response = client.get("/submissions?project_id=zero") data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" + assert data["data"] == {} def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2", f"{API_HOST}/submissions/3" @@ -48,7 +56,9 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): response = client.get("/submissions?uid=student01") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1" ] @@ -57,7 +67,9 @@ def test_get_submissions_project(self, client: FlaskClient, session: Session): response = client.get("/submissions?project_id=1") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2" ] @@ -67,7 +79,9 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio response = client.get("/submissions?uid=student01&project_id=1") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1" ] @@ -79,7 +93,9 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The uid data field is required" + assert data["data"] == {} def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing user""" @@ -89,7 +105,9 @@ def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" + assert data["data"] == {} def test_post_submissions_no_project(self, client: FlaskClient, session: Session): """Test posting a submission without specifying a project""" @@ -98,7 +116,9 @@ def test_post_submissions_no_project(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The project_id data field is required" + assert data["data"] == {} def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project""" @@ -108,7 +128,9 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" + assert data["data"] == {} def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project of the wrong type""" @@ -118,7 +140,9 @@ def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" + assert data["data"] == {} def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" @@ -129,7 +153,9 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" + assert data["data"] == {} def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading type""" @@ -140,10 +166,9 @@ def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" - - def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): - """Test posting a submission with no or wrong files""" + assert data["data"] == {} def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" @@ -154,12 +179,13 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 201 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - submission = session.query(m_submissions).filter_by( - uid="student01", project_id=1, grading=16 - ).first() - assert submission is not None + submission_id = int(data["data"]["submission"].split("/")[-1]) + submission = session.get(m_submissions, submission_id) + assert submission.uid == "student01" and submission.project_id == 1 \ + and submission.grading == 16 ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -167,21 +193,25 @@ def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.get("/submissions/100") data = response.json assert response.status_code == 404 + assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" + assert data["data"] == {} def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" response = client.get("/submissions/1") data = response.json assert response.status_code == 200 - assert data["submission"] == { - "submission_id": 1, - "uid": "student01", - "project_id": 1, + assert data["url"] == f"{API_HOST}/submissions/1" + assert data["message"] == "Successfully fetched the submission" + assert data["data"]["submission"] == { + "id": 1, + "user": f"{API_HOST}/users/student01", + "project": f"{API_HOST}/projects/1", "grading": 16, - "submission_time": "Thu, 14 Mar 2024 11:00:00 GMT", - "submission_path": "/submissions/1", - "submission_status": True + "time": "Thu, 14 Mar 2024 11:00:00 GMT", + "path": "/submissions/1", + "status": True } ### PATCH SUBMISSION ### @@ -190,28 +220,36 @@ def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.patch("/submissions/100", data={"grading": 20}) data = response.json assert response.status_code == 404 + assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" + assert data["data"] == {} def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" response = client.patch("/submissions/2", data={"grading": 100}) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" + assert data["data"] == {} def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading type""" response = client.patch("/submissions/2", data={"grading": "zero"}) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" + assert data["data"] == {} def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" response = client.patch("/submissions/2", data={"grading": 20}) data = response.json assert response.status_code == 200 + assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Submission (submission_id=2) patched" + assert data["data"] == {} submission = session.get(m_submissions, 2) assert submission.grading == 20 @@ -222,14 +260,18 @@ def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session) response = client.delete("submissions/100") data = response.json assert response.status_code == 404 + assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" + assert data["data"] == {} def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" response = client.delete("submissions/1") data = response.json assert response.status_code == 200 + assert data["url"] == f"{API_HOST}/submissions/1" assert data["message"] == "Submission (submission_id=1) deleted" + assert data["data"] == {} submission = session.get(m_submissions, 1) assert submission is None From 12830d56134c0bec58a0c147184de0f614e59dab Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 13:42:58 +0100 Subject: [PATCH 023/144] added query agent containing functions that can be used by multiple endpoints --- backend/project/utils/query_agent.py | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 backend/project/utils/query_agent.py diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py new file mode 100644 index 00000000..947089e9 --- /dev/null +++ b/backend/project/utils/query_agent.py @@ -0,0 +1,119 @@ +""" +This module contains the functions to interact with the database. It contains functions to +delete, insert and query entries from the database. The functions are used by the routes +to interact with the database. +""" + +from typing import Dict, List, Union +from flask import jsonify +from sqlalchemy import and_ +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.orm.query import Query +from sqlalchemy.exc import SQLAlchemyError +from project.db_in import db + +def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int): + """ + Deletes an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to delete from. + column_name: str - The name of the column to delete from. + id: int - The id of the entry to delete. + + Returns: + A message indicating that the resource was deleted successfully if the operation was + successful, otherwise a message indicating that something went wrong while deleting from + the database. + """ + try: + result: DeclarativeMeta = model.query.filter( + getattr(model, column_name) == column_id + ).first() + + if not result: + return {"message": "Resource not found"}, 404 + db.session.delete(result) + db.session.commit() + return {"message": "Resource deleted successfully"}, 200 + except SQLAlchemyError: + return {"error": "Something went wrong while deleting from the database."}, 500 + +def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): + """ + Inserts a new entry into the database giving the model corresponding to a certain table + and the data to insert. + + Args: + model: DeclarativeMeta - The model corresponding to the table to insert into. + data: Dict[str, Union[str, int]] - The data to insert into the table. + + Returns: + The new entry inserted into the database if the operation was successful, otherwise + a message indicating that something went wrong while inserting into the database. + """ + try: + new_instance: DeclarativeMeta = model(**data) + db.session.add(new_instance) + db.session.commit() + return new_instance, 201 + except SQLAlchemyError: + return {"error": "Something went wrong while inserting into the database."}, 500 + +def query_selected_from_model(model: DeclarativeMeta, + select_values: List[str] = None, + filters: Dict[str, Union[str, int]]=None): + """ + Query all entries from the database giving the model corresponding to a certain table, + the columns to select and the filters to apply. + + Args: + model: DeclarativeMeta - The model corresponding to the table to query from. + select_values: List[str] - The values to select from the table. + filters: Dict[str, Union[str, int]] - The filters to apply to the query. + + Returns: + The entries queried from the database if they exist, otherwise a message indicating + that the resource was not found. + """ + try: + query: Query = model.query + if select_values: + query = query.with_entities(*[getattr(model, value) for value in select_values]) + if filters: + conditions: List[bool] = [] + for key, value in filters.items(): + conditions.append(getattr(model, key) == value) + query = query.filter(and_(*conditions)) + results: List[DeclarativeMeta] = query.all() + return jsonify(results), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while querying the database."}, 500 + +def query_by_id_from_model(model: DeclarativeMeta, + column_name: str, + column_id: int, + not_found_message: str="Resource not found"): + """ + Query an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to query from. + column_name: str - The name of the column to query from. + id: int - The id of the entry to query. + not_found_message: str - The message to return if the entry is not found. + + Returns: + The entry queried from the database if it exists, otherwise a message indicating + that the resource was not found. + + """ + try: + result: Query = model.query.filter(getattr(model, column_name) == column_id).first() + if not result: + return {"message": not_found_message}, 404 + return jsonify(result), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while querying the database."}, 500 From 81e3d3c955c3e483d792bff84f097d7f327fbb4c Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:25:29 +0100 Subject: [PATCH 024/144] loading env variables is only necessary in __main__ --- backend/project/__main__.py | 5 ++--- backend/project/endpoints/courses.py | 2 -- backend/project/endpoints/projects/project_detail.py | 2 -- backend/project/sessionmaker.py | 3 --- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 2f312c85..a4bd122b 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,11 +1,10 @@ """Main entry point for the application.""" -from sys import path +from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url -path.append(".") - if __name__ == "__main__": + load_dotenv() app = create_app_with_db(url) app.run(debug=True) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index 1903e9e3..a09e7cfb 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -1,7 +1,6 @@ """Course api point""" from os import getenv -from dotenv import load_dotenv from flask import Blueprint, jsonify, request from flask import abort from flask_restful import Api, Resource @@ -15,7 +14,6 @@ courses_bp = Blueprint("courses", __name__) courses_api = Api(courses_bp) -load_dotenv() API_URL = getenv("API_HOST") diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 88989247..5428dcac 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -4,7 +4,6 @@ the corresponding project is 1 """ from os import getenv -from dotenv import load_dotenv from flask import jsonify from flask_restful import Resource, abort @@ -14,7 +13,6 @@ from project import db from project.models.projects import Project -load_dotenv() API_URL = getenv('API_HOST') class ProjectDetail(Resource): diff --git a/backend/project/sessionmaker.py b/backend/project/sessionmaker.py index 9fbf1cad..0ab68f8e 100644 --- a/backend/project/sessionmaker.py +++ b/backend/project/sessionmaker.py @@ -1,11 +1,8 @@ """initialise a datab session""" from os import getenv -from dotenv import load_dotenv from sqlalchemy import create_engine, URL from sqlalchemy.orm import sessionmaker -load_dotenv() - url = URL.create( drivername="postgresql", username=getenv("POSTGRES_USER"), From 658dfd35b355638b08c51d01b19c2352ae629fca Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:30:16 +0100 Subject: [PATCH 025/144] removed unneeded load_dotenv --- backend/project/db_in.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/project/db_in.py b/backend/project/db_in.py index ebcc02dd..57a572fa 100644 --- a/backend/project/db_in.py +++ b/backend/project/db_in.py @@ -2,13 +2,10 @@ import os from flask_sqlalchemy import SQLAlchemy -from dotenv import load_dotenv from sqlalchemy import URL db = SQLAlchemy() -load_dotenv() - DATABSE_NAME = os.getenv("POSTGRES_DB") DATABASE_USER = os.getenv("POSTGRES_USER") DATABASE_PASSWORD = os.getenv("POSTGRES_PASSWORD") From 7dd56821d8141508d48aecd0d44fde32986c6a35 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:32:46 +0100 Subject: [PATCH 026/144] completed functions that are ought to be used by multiple endpoints or files --- backend/project/utils/misc.py | 38 +++++++++++++++++++++ backend/project/utils/query_agent.py | 50 ++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 backend/project/utils/misc.py diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py new file mode 100644 index 00000000..3c348175 --- /dev/null +++ b/backend/project/utils/misc.py @@ -0,0 +1,38 @@ +from typing import Dict, List +from urllib.parse import urljoin + +def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[str, str]: + """ + Maps keys to a url using a url mapper. + + Args: + url_mapper: Dict[str, str] - A dictionary that maps keys to urls. + data: Dict[str, str] - The data to map to urls. + + Returns: + A dictionary with the keys mapped to the urls. + """ + for key, value in data.items(): + if key in url_mapper: + data[key] = urljoin(url_mapper[key], str(value)) + return data + +def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): + """ + Maps all keys to a url using a url mapper. + + Args: + url_mapper: Dict[str, str] - A dictionary that maps keys to urls. + data: List[Dict[str, str]] - The data to map to urls. + + Returns: + A list of dictionaries with the keys mapped to the urls. + """ + print(data) + return [map_keys_to_url(url_mapper, entry) for entry in data] + +def model_to_dict(instance): + return {column.key: getattr(instance, column.key) for column in instance.__table__.columns} + +def models_to_dict(instances): + return [model_to_dict(instance) for instance in instances] \ No newline at end of file diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 947089e9..9b348350 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -5,12 +5,14 @@ """ from typing import Dict, List, Union +from urllib.parse import urljoin from flask import jsonify from sqlalchemy import and_ from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError from project.db_in import db +from project.utils.misc import map_all_keys_to_url, models_to_dict def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int): """ @@ -40,7 +42,9 @@ def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: except SQLAlchemyError: return {"error": "Something went wrong while deleting from the database."}, 500 -def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): +def insert_into_model(model: DeclarativeMeta, + data: Dict[str, Union[str, int]], + response_url_base: str): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -48,6 +52,7 @@ def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): Args: model: DeclarativeMeta - The model corresponding to the table to insert into. data: Dict[str, Union[str, int]] - The data to insert into the table. + response_url_base: str - The base url to use in the response. Returns: The new entry inserted into the database if the operation was successful, otherwise @@ -57,20 +62,28 @@ def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): new_instance: DeclarativeMeta = model(**data) db.session.add(new_instance) db.session.commit() - return new_instance, 201 + return {"data": new_instance, + "message": "Object created succesfully.", + "url": urljoin(response_url_base, str(new_instance.project_id))}, 201 except SQLAlchemyError: - return {"error": "Something went wrong while inserting into the database."}, 500 + return {"error": "Something went wrong while inserting into the database.", + "url": response_url_base}, 500 def query_selected_from_model(model: DeclarativeMeta, + response_url: str, + url_mapper: Dict[str, str] = None, select_values: List[str] = None, filters: Dict[str, Union[str, int]]=None): """ - Query all entries from the database giving the model corresponding to a certain table, - the columns to select and the filters to apply. + Query entries from the database giving the model corresponding to a certain table and + the filters to apply to the query. + Args: model: DeclarativeMeta - The model corresponding to the table to query from. - select_values: List[str] - The values to select from the table. + response_url: str - The base url to use in the response. + url_mapper: Dict[str, str] - A dictionary to map the keys of the response to urls. + select_values: List[str] - The columns to select from the table. filters: Dict[str, Union[str, int]] - The filters to apply to the query. Returns: @@ -79,17 +92,32 @@ def query_selected_from_model(model: DeclarativeMeta, """ try: query: Query = model.query - if select_values: - query = query.with_entities(*[getattr(model, value) for value in select_values]) if filters: conditions: List[bool] = [] for key, value in filters.items(): conditions.append(getattr(model, key) == value) query = query.filter(and_(*conditions)) - results: List[DeclarativeMeta] = query.all() - return jsonify(results), 200 + + if select_values: + query = query.with_entities(*[getattr(model, value) for value in select_values]) + query_result = query.all() + results = [] + for instance in query_result: + selected_instance = {} + for value in select_values: + selected_instance[value] = getattr(instance, value) + results.append(selected_instance) + else: + results = models_to_dict(query.all()) + if url_mapper: + results = map_all_keys_to_url(url_mapper, results) + response = {"data": results, + "message": "Resources fetched successfully", + "url": response_url} + return jsonify(response), 200 except SQLAlchemyError: - return {"error": "Something went wrong while querying the database."}, 500 + return {"error": "Something went wrong while querying the database.", + "url": response_url}, 500 def query_by_id_from_model(model: DeclarativeMeta, column_name: str, From e42c57ded2cbedd64217d31e0aa8808e0b5e4771 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:33:06 +0100 Subject: [PATCH 027/144] simplified endpoint functions by using query_agent functions --- .../project/endpoints/projects/projects.py | 78 ++++--------------- 1 file changed, 13 insertions(+), 65 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index f444e283..cb516b1a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,18 +2,14 @@ Module that implements the /projects endpoint of the API """ from os import getenv -from dotenv import load_dotenv -from flask import jsonify +from flask import request from flask_restful import Resource -from sqlalchemy import exc +from urllib.parse import urljoin - -from project import db from project.models.projects import Project -from project.endpoints.projects.endpoint_parser import parse_project_params +from project.utils.query_agent import query_selected_from_model, insert_into_model -load_dotenv() API_URL = getenv('API_HOST') class ProjectsEndpoint(Resource): @@ -28,67 +24,19 @@ def get(self): Get method for listing all available projects that are currently in the API """ - try: - projects = Project.query.with_entities( - Project.project_id, - Project.title, - Project.descriptions - ).all() - - results = [{ - "project_id": row[0], - "title": row[1], - "descriptions": row[2] - } for row in projects] - - # return all valid entries for a project and return a 200 OK code - return { - "data": results, - "url": f"{API_URL}/projects", - "message": "Projects fetched successfully" - }, 200 - except exc.SQLAlchemyError: - return { - "message": "Something unexpected happenend when trying to get the projects", - "url": f"{API_URL}/projects" - }, 500 + SUMMARY_FIELDS = ["project_id", "title", "descriptions"] + response_url = urljoin(API_URL, "/projects") + return query_selected_from_model(Project, + response_url, + select_values=SUMMARY_FIELDS, + url_mapper={"project_id": response_url}, + filters=request.args + ) def post(self): """ Post functionality for project using flask_restfull parse lib """ - args = parse_project_params() - - # create a new project object to add in the API later - new_project = Project( - title=args['title'], - descriptions=args['descriptions'], - assignment_file=args['assignment_file'], - deadline=args['deadline'], - course_id=args['course_id'], - visible_for_students=args['visible_for_students'], - archieved=args['archieved'], - test_path=args['test_path'], - script_name=args['script_name'], - regex_expressions=args['regex_expressions'] - ) - - # add the new project to the database and commit the changes - - try: - db.session.add(new_project) - db.session.commit() - new_project_json = jsonify(new_project).json - - return { - "url": f"{API_URL}/projects/{new_project_json['project_id']}", - "message": "Project posted successfully", - "data": new_project_json - }, 201 - except exc.SQLAlchemyError: - return ({ - "url": f"{API_URL}/projects", - "message": "Something unexpected happenend when trying to add a new project", - "data": jsonify(new_project).json - }, 500) + + return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) From 3467bb22b6213a2a81878e480a2248b53b949afa Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:41:07 +0100 Subject: [PATCH 028/144] fixed linting --- .../project/endpoints/projects/projects.py | 7 ++--- backend/project/utils/misc.py | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index cb516b1a..638cbabc 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,10 +2,10 @@ Module that implements the /projects endpoint of the API """ from os import getenv +from urllib.parse import urljoin from flask import request from flask_restful import Resource -from urllib.parse import urljoin from project.models.projects import Project from project.utils.query_agent import query_selected_from_model, insert_into_model @@ -24,11 +24,10 @@ def get(self): Get method for listing all available projects that are currently in the API """ - SUMMARY_FIELDS = ["project_id", "title", "descriptions"] response_url = urljoin(API_URL, "/projects") return query_selected_from_model(Project, response_url, - select_values=SUMMARY_FIELDS, + select_values=["project_id", "title", "descriptions"], url_mapper={"project_id": response_url}, filters=request.args ) @@ -38,5 +37,5 @@ def post(self): Post functionality for project using flask_restfull parse lib """ - + return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 3c348175..cd20a6f7 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -1,5 +1,12 @@ +""" +This module contains functions that are not related to anything specific but +are ought to be used throughout the project. +""" + from typing import Dict, List from urllib.parse import urljoin +from sqlalchemy.ext.declarative import DeclarativeMeta + def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[str, str]: """ @@ -31,8 +38,26 @@ def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): print(data) return [map_keys_to_url(url_mapper, entry) for entry in data] -def model_to_dict(instance): +def model_to_dict(instance: DeclarativeMeta) -> Dict[str, str]: + """ + Converts an sqlalchemy model to a dictionary. + + Args: + instance: DeclarativeMeta - The instance of the model to convert to a dictionary. + + Returns: + A dictionary with the keys and values of the model. + """ return {column.key: getattr(instance, column.key) for column in instance.__table__.columns} -def models_to_dict(instances): - return [model_to_dict(instance) for instance in instances] \ No newline at end of file +def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: + """ + Converts a list of sqlalchemy models to a list of dictionaries. + + Args: + instances: List[DeclarativeMeta] - The instances of the models to convert to dictionaries. + + Returns: + A list of dictionaries with the keys and values of the models. + """ + return [model_to_dict(instance) for instance in instances] From 69df26e9e720f9f1adb80024051937b6e36aefcc Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:55:16 +0100 Subject: [PATCH 029/144] fixed urljoin incorrectly joining url --- backend/project/endpoints/projects/projects.py | 16 +++++++++------- backend/project/utils/misc.py | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 638cbabc..d273bfca 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -24,13 +24,15 @@ def get(self): Get method for listing all available projects that are currently in the API """ - response_url = urljoin(API_URL, "/projects") - return query_selected_from_model(Project, - response_url, - select_values=["project_id", "title", "descriptions"], - url_mapper={"project_id": response_url}, - filters=request.args - ) + + response_url = urljoin(API_URL, "projects") + return query_selected_from_model( + Project, + response_url, + select_values=["project_id", "title", "descriptions"], + url_mapper={"project_id": response_url}, + filters=request.args + ) def post(self): """ diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index cd20a6f7..2c82fbac 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -21,7 +21,9 @@ def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[st """ for key, value in data.items(): if key in url_mapper: - data[key] = urljoin(url_mapper[key], str(value)) + data[key] = urljoin(url_mapper[key] + "/", str(value)) + print(url_mapper) + print(data) return data def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): @@ -35,7 +37,6 @@ def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): Returns: A list of dictionaries with the keys mapped to the urls. """ - print(data) return [map_keys_to_url(url_mapper, entry) for entry in data] def model_to_dict(instance: DeclarativeMeta) -> Dict[str, str]: From e836f5037268a742b45877ed007cc3e5621b0e5d Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:02:57 +0100 Subject: [PATCH 030/144] lint: removed trailing whitepsace --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index d273bfca..68600034 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -24,7 +24,7 @@ def get(self): Get method for listing all available projects that are currently in the API """ - + response_url = urljoin(API_URL, "projects") return query_selected_from_model( Project, From e26c015bdb5a3080c0615690d5138a442cb61fb0 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:41:17 +0100 Subject: [PATCH 031/144] completely replaced functionality with query_agent functions --- .../endpoints/projects/project_detail.py | 97 +++++-------------- .../project/endpoints/projects/projects.py | 5 +- 2 files changed, 27 insertions(+), 75 deletions(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 5428dcac..f2ce1a00 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -4,16 +4,18 @@ the corresponding project is 1 """ from os import getenv +from urllib.parse import urljoin -from flask import jsonify -from flask_restful import Resource, abort -from sqlalchemy import exc -from project.endpoints.projects.endpoint_parser import parse_project_params +from flask import request +from flask_restful import Resource from project import db from project.models.projects import Project +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, patch_by_id_from_model + API_URL = getenv('API_HOST') +RESPONSE_URL = urljoin(API_URL, "projects") class ProjectDetail(Resource): """ @@ -22,14 +24,6 @@ class ProjectDetail(Resource): for implementing get, delete and put methods """ - def abort_if_not_present(self, project): - """ - Check if the project exists in the database - and if not abort the request and give back a 404 not found - """ - if project is None: - abort(404) - def get(self, project_id): """ Get method for listing a specific project @@ -37,22 +31,11 @@ def get(self, project_id): the id fetched from the url with the reaparse """ - try: - # fetch the project with the id that is specified in the url - project = Project.query.filter_by(project_id=project_id).first() - self.abort_if_not_present(project) - - # return the fetched project and return 200 OK status - return { - "data": jsonify(project).json, - "url": f"{API_URL}/projects/{project_id}", - "message": "Got project successfully" - }, 200 - except exc.SQLAlchemyError: - return { - "message": "Internal server error", - "url": f"{API_URL}/projects/{project_id}" - }, 500 + return query_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL) def patch(self, project_id): """ @@ -60,30 +43,13 @@ def patch(self, project_id): filtered by id of that specific project """ - # get the project that need to be edited - project = Project.query.filter_by(project_id=project_id).first() - - # check which values are not None in the dict - # if it is not None it needs to be modified in the database - - # commit the changes and return the 200 OK code if it succeeds, else 500 - try: - var_dict = parse_project_params() - for key, value in var_dict.items(): - setattr(project, key, value) - db.session.commit() - # get the updated version - return { - "message": f"Succesfully changed project with id: {id}", - "url": f"{API_URL}/projects/{id}", - "data": project - }, 200 - except exc.SQLAlchemyError: - db.session.rollback() - return { - "message": f"Something unexpected happenend when trying to edit project {id}", - "url": f"{API_URL}/projects/{id}" - }, 500 + return patch_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL, + request.json + ) def delete(self, project_id): """ @@ -91,25 +57,8 @@ def delete(self, project_id): done by project id """ - # fetch the project that needs to be removed - deleted_project = Project.query.filter_by(project_id=project_id).first() - - # check if its an existing one - self.abort_if_not_present(deleted_project) - - # if it exists delete it and commit the changes in the database - try: - db.session.delete(deleted_project) - db.session.commit() - - # return 200 if content is deleted succesfully - return { - "message": f"Project with id: {id} deleted successfully", - "url": f"{API_URL}/projects/{id} deleted successfully!", - "data": deleted_project - }, 200 - except exc.SQLAlchemyError: - return { - "message": f"Something unexpected happened when removing project {project_id}", - "url": f"{API_URL}/projects/{id}" - }, 500 + return delete_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 68600034..0834988f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -40,4 +40,7 @@ def post(self): using flask_restfull parse lib """ - return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) + return insert_into_model( + Project,request.json, + urljoin(API_URL, "/projects"), + "project_id") From fd941b2b33d3cc44d02324ced058a459587a8476 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:41:35 +0100 Subject: [PATCH 032/144] added functionality for patching an entry in the database --- backend/project/utils/query_agent.py | 69 +++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 9b348350..3837820f 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -14,7 +14,7 @@ from project.db_in import db from project.utils.misc import map_all_keys_to_url, models_to_dict -def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int): +def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int, base_url: str): """ Deletes an entry from the database giving the model corresponding to a certain table, a column name and its value. @@ -35,16 +35,21 @@ def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: ).first() if not result: - return {"message": "Resource not found"}, 404 + return { + "message": "Resource not found", + "url": base_url}, 404 db.session.delete(result) db.session.commit() - return {"message": "Resource deleted successfully"}, 200 + return {"message": "Resource deleted successfully", + "url": base_url}, 200 except SQLAlchemyError: - return {"error": "Something went wrong while deleting from the database."}, 500 + return {"error": "Something went wrong while deleting from the database.", + "url": base_url}, 500 def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], - response_url_base: str): + response_url_base: str, + url_id_field: str): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -60,11 +65,13 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance: DeclarativeMeta = model(**data) + db.session.add(new_instance) db.session.commit() - return {"data": new_instance, + return jsonify({ + "data": new_instance, "message": "Object created succesfully.", - "url": urljoin(response_url_base, str(new_instance.project_id))}, 201 + "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 except SQLAlchemyError: return {"error": "Something went wrong while inserting into the database.", "url": response_url_base}, 500 @@ -122,7 +129,7 @@ def query_selected_from_model(model: DeclarativeMeta, def query_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int, - not_found_message: str="Resource not found"): + base_url: str): """ Query an entry from the database giving the model corresponding to a certain table, a column name and its value. @@ -141,7 +148,47 @@ def query_by_id_from_model(model: DeclarativeMeta, try: result: Query = model.query.filter(getattr(model, column_name) == column_id).first() if not result: - return {"message": not_found_message}, 404 - return jsonify(result), 200 + return {"message": "Resource not found", "url": base_url}, 404 + print(column_id) + return jsonify({ + "data": result, + "message": "Resource fetched correctly", + "url": urljoin(base_url + "/", str(column_id))}), 200 except SQLAlchemyError: - return {"error": "Something went wrong while querying the database."}, 500 + return { + "error": "Something went wrong while querying the database.", + "url": base_url}, 500 + +def patch_by_id_from_model(model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str, + data: Dict[str, Union[str, int]]): + """ + Update an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to update. + column_name: str - The name of the column to update. + id: int - The id of the entry to update. + data: Dict[str, Union[str, int]] - The data to update the entry with. + + Returns: + The entry updated from the database if the operation was successful, otherwise + a message indicating that something went wrong while updating the entry. + """ + try: + result: Query = model.query.filter(getattr(model, column_name) == column_id).first() + if not result: + return {"message": "Resource not found", "url": base_url}, 404 + for key, value in data.items(): + setattr(result, key, value) + db.session.commit() + return jsonify({ + "data": result, + "message": "Resource updated successfully", + "url": urljoin(base_url + "/", str(column_id))}), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while updating the database.", + "url": base_url}, 500 From 073d6c57c329f9a40a31fd7583a6a5656803baeb Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:46:55 +0100 Subject: [PATCH 033/144] fixed linting --- backend/project/endpoints/projects/project_detail.py | 4 ++-- backend/project/utils/query_agent.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index f2ce1a00..e2314bd9 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -9,9 +9,9 @@ from flask import request from flask_restful import Resource -from project import db from project.models.projects import Project -from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, patch_by_id_from_model +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ + patch_by_id_from_model API_URL = getenv('API_HOST') diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 3837820f..c4053df4 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -14,7 +14,11 @@ from project.db_in import db from project.utils.misc import map_all_keys_to_url, models_to_dict -def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int, base_url: str): +def delete_by_id_from_model( + model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str): """ Deletes an entry from the database giving the model corresponding to a certain table, a column name and its value. @@ -65,7 +69,7 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance: DeclarativeMeta = model(**data) - + db.session.add(new_instance) db.session.commit() return jsonify({ From fd2ae8379e63c91d15526d758323a5a00f3b8ea9 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:16:22 +0100 Subject: [PATCH 034/144] filtered queries and forms to only contain entries that are valid in table --- backend/project/utils/query_agent.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index c4053df4..d0595afd 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -7,12 +7,12 @@ from typing import Dict, List, Union from urllib.parse import urljoin from flask import jsonify -from sqlalchemy import and_ +from sqlalchemy import and_, inspect from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError from project.db_in import db -from project.utils.misc import map_all_keys_to_url, models_to_dict +from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields def delete_by_id_from_model( model: DeclarativeMeta, @@ -53,7 +53,8 @@ def delete_by_id_from_model( def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, - url_id_field: str): + url_id_field: str, + required_fields: List[str] = []): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -68,17 +69,24 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - new_instance: DeclarativeMeta = model(**data) - + # Check if all non-nullable fields are present in the data + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}", + "url": response_url_base}, 400 + + filtered_data = filter_model_fields(model, data) + new_instance: DeclarativeMeta = model(**filtered_data) db.session.add(new_instance) db.session.commit() return jsonify({ "data": new_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError: - return {"error": "Something went wrong while inserting into the database.", - "url": response_url_base}, 500 + except SQLAlchemyError as e: + return jsonify({"error": "Something went wrong while inserting into the database.", + "url": response_url_base}), 500 def query_selected_from_model(model: DeclarativeMeta, response_url: str, @@ -104,8 +112,9 @@ def query_selected_from_model(model: DeclarativeMeta, try: query: Query = model.query if filters: + filtered_filters = filter_model_fields(model, filters) conditions: List[bool] = [] - for key, value in filters.items(): + for key, value in filtered_filters.items(): conditions.append(getattr(model, key) == value) query = query.filter(and_(*conditions)) From d675fe69348b9e2fa4c682518a96677cd24eb36f Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:17:57 +0100 Subject: [PATCH 035/144] created function that filters dict keys that are not in table --- backend/project/utils/misc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 2c82fbac..9d313467 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -62,3 +62,7 @@ def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: A list of dictionaries with the keys and values of the models. """ return [model_to_dict(instance) for instance in instances] + + +def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): + return {key: value for key, value in data.items() if hasattr(model, key)} \ No newline at end of file From 2298d8c2536a19b4c9f9035cadb26508a143561a Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:19:31 +0100 Subject: [PATCH 036/144] made class serializable --- backend/project/models/courses.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index dc778706..8d3f0651 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,14 +1,16 @@ """The Course model""" +from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String from project import db +@dataclass class Course(db.Model): """This class described the courses table, a course has an id, name, optional ufora id and the teacher that created it""" __tablename__ = "courses" - course_id = Column(Integer, primary_key=True) - name = Column(String(50), nullable=False) - ufora_id = Column(String(50), nullable=True) - teacher = Column(String(255), ForeignKey("users.uid"), nullable=False) + course_id: int = Column(Integer, primary_key=True) + name: str = Column(String(50), nullable=False) + ufora_id: str = Column(String(50), nullable=True) + teacher: str = Column(String(255), ForeignKey("users.uid"), nullable=False) From d0e9a10bcc23b38b7ceff44df02a942bbdb910d9 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:20:01 +0100 Subject: [PATCH 037/144] url query is not a valid authentication method, filtered out option --- backend/tests/endpoints/courses_test.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 4df98cd5..9beb64fe 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -25,19 +25,6 @@ def test_post_courses(self, courses_init_db, client, course_data, invalid_course assert course is not None assert course.teacher == "Bart" - response = client.post( - "/courses?uid=Jef", json=course_data - ) # non existent user - assert response.status_code == 404 - - response = client.post( - "/courses?uid=student_sel2_0", json=course_data - ) # existent user but no rights - assert response.status_code == 403 - - response = client.post("/courses", json=course_data) # bad link, no uid passed - assert response.status_code == 400 - response = client.post( "/courses?uid=Bart", json=invalid_course ) # invalid course @@ -87,12 +74,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client assert response.status_code == 400 sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - - response = client.post( - sel2_admins_link + "?uid=student_sel2_0", # unauthorized user - json={"admin_uid": "Rien"}, - ) - assert response.status_code == 403 + course_admins = [ s.uid for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() From 6b3e73300e6aaa82a82671b66f08c52acb6918f5 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:17:33 +0100 Subject: [PATCH 038/144] using query_agent functions to prevent code duplication --- backend/project/endpoints/courses.py | 256 ++++++++++----------------- 1 file changed, 89 insertions(+), 167 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index a09e7cfb..4954eea0 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -1,6 +1,10 @@ """Course api point""" from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + from flask import Blueprint, jsonify, request from flask import abort from flask_restful import Api, Resource @@ -8,14 +12,17 @@ from project.models.course_relations import CourseAdmin, CourseStudent from project.models.users import User from project.models.courses import Course -from project.models.projects import Project +from project.utils.query_agent import query_selected_from_model, \ + insert_into_model,delete_by_id_from_model, \ + patch_by_id_from_model from project import db courses_bp = Blueprint("courses", __name__) courses_api = Api(courses_bp) +load_dotenv() API_URL = getenv("API_HOST") - +RESPONSE_URL = urljoin(API_URL + "/", "courses") def execute_query_abort_if_db_error(query, url, query_all=False): """ @@ -231,7 +238,6 @@ def get_course_abort_if_not_found(course_id): return course - class CourseForUser(Resource): """Api endpoint for the /courses link""" @@ -241,72 +247,27 @@ def get(self): to get all courses and filter by given query parameter like /courses?parameter=... parameters can be either one of the following: teacher,ufora_id,name. """ - query = Course.query - if "teacher" in request.args: - query = query.filter_by(course_id=request.args.get("teacher")) - if "ufora_id" in request.args: - query = query.filter_by(ufora_id=request.args.get("ufora_id")) - if "name" in request.args: - query = query.filter_by(name=request.args.get("name")) - results = execute_query_abort_if_db_error( - query, url=API_URL + "/courses", query_all=True + + return query_selected_from_model( + Course, + RESPONSE_URL, + url_mapper={"course_id": RESPONSE_URL}, + filters=request.args ) - detail_urls = [ - f"{API_URL}/courses/{str(course.course_id)}" for course in results - ] - message = "Succesfully retrieved all courses with given parameters" - response = json_message(message) - response["data"] = detail_urls - response["url"] = API_URL + "/courses" - return response def post(self): """ This function will create a new course if the body of the post contains a name and uid is an admin or teacher """ - abort_url = API_URL + "/courses" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - user = abort_if_no_user_found_for_uid(uid, abort_url) - - if not user.is_teacher: - message = ( - "Only teachers or admins can create new courses, you are unauthorized" - ) - return json_message(message), 403 - - data = request.get_json() - - if "name" not in data: - message = "Missing 'name' in the request body" - return json_message(message), 400 - - name = data["name"] - new_course = Course(name=name, teacher=uid) - if "ufora_id" in data: - new_course.ufora_id = data["ufora_id"] - add_abort_if_error(new_course, abort_url) - commit_abort_if_error(abort_url) - - admin_course = CourseAdmin(uid=uid, course_id=new_course.course_id) - add_abort_if_error(admin_course, abort_url) - commit_abort_if_error(abort_url) - - message = (f"Course with name: {name} and" - f"course_id:{new_course.course_id} was succesfully created") - response = json_message(message) - data = { - "course_id": API_URL + "/courses/" + str(new_course.course_id), - "name": new_course.name, - "teacher": API_URL + "/users/" + new_course.teacher, - "ufora_id": new_course.ufora_id if new_course.ufora_id else "None", - } - response["data"] = data - response["url"] = API_URL + "/courses/" + str(new_course.course_id) - return response, 201 + return insert_into_model( + Course, + request.json, + RESPONSE_URL, + "course_id", + required_fields=["name", "teacher"] + ) class CourseByCourseId(Resource): @@ -324,119 +285,76 @@ def get(self, course_id): ] } """ - abort_url = API_URL + "/courses" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - admin = get_admin_relation(uid, course_id) - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student = execute_query_abort_if_db_error(query, abort_url) - - if not (admin or student): - message = "User is not an admin, nor a student of this course" - return json_message(message), 404 - - course = get_course_abort_if_not_found(course_id) - query = Project.query.filter_by(course_id=course_id) - abort_url = API_URL + "/courses/" + str(course_id) - # course does exist so url should be to the id - project_uids = [ - API_URL + "/projects/" + project.project_id - for project in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + admin.uid - for admin in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + student.uid - for student in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - - data = { - "ufora_id": course.ufora_id, - "teacher": API_URL + "/users/" + course.teacher, - "admins": admin_uids, - "students": student_uids, - "projects": project_uids, - } - response = json_message( - "Succesfully retrieved course with course_id: " + str(course_id) - ) - response["data"] = data - response["url"] = API_URL + "/courses/" + str(course_id) - return response + try: + course_details = db.session.query( + Course.course_id, + Course.name, + Course.ufora_id, + Course.teacher + ).filter( + Course.course_id == course_id).first() + + if not course_details: + return { + "message": "Course not found", + "url": RESPONSE_URL + }, 404 + + admins = db.session.query(CourseAdmin.uid).filter( + CourseAdmin.course_id == course_id + ).all() + + students = db.session.query(CourseStudent.uid).filter( + CourseStudent.course_id == course_id + ).all() + + user_url = urljoin(API_URL + "/", "users") + + admin_ids = [ urljoin(user_url + "/" , admin[0]) for admin in admins] + student_ids = [ urljoin(user_url + "/", student[0]) for student in students] + + result = { + 'course_id': course_details.course_id, + 'name': course_details.name, + 'ufora_id': course_details.ufora_id, + 'teacher': course_details.teacher, + 'admins': admin_ids, + 'students': student_ids + } + + return { + "message": "Succesfully retrieved course with course_id: " + str(course_id), + "data": result, + "url": urljoin(RESPONSE_URL + "/", str(course_id)) + } + except SQLAlchemyError: + return { + "error": "Something went wrong while querying the database.", + "url": RESPONSE_URL}, 500 def delete(self, course_id): """ This function will delete the course with course_id """ - abort_url = API_URL + "/courses/" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - admin = get_admin_relation(uid, course_id) - - if not admin: - message = "You are not an admin of this course and so you cannot delete it" - return json_message(message), 403 - - course = get_course_abort_if_not_found(course_id) - abort_url = API_URL + "/courses/" + str(course_id) - # course does exist so url should be to the id - delete_abort_if_error(course, abort_url) - commit_abort_if_error(abort_url) - - response = { - "message": "Succesfully deleted course with course_id: " + str(course_id), - "url": API_URL + "/courses", - } - return response + return delete_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL + ) def patch(self, course_id): """ This function will update the course with course_id """ - abort_url = API_URL + "/courses/" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - admin = get_admin_relation(uid, course_id) - - if not admin: - message = "You are not an admin of this course and so you cannot update it" - return json_message(message), 403 - data = request.get_json() - course = get_course_abort_if_not_found(course_id) - abort_url = API_URL + "/courses/" + str(course_id) - if "name" in data: - course.name = data["name"] - if "teacher" in data: - course.teacher = data["teacher"] - if "ufora_id" in data: - course.ufora_id = data["ufora_id"] - - commit_abort_if_error(abort_url) - response = json_message( - "Succesfully updated course with course_id: " + str(course_id) + return patch_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL, + request.json ) - response["url"] = API_URL + "/courses/" + str(course_id) - data = { - "course_id": API_URL + "/courses/" + str(course.course_id), - "name": course.name, - "teacher": API_URL + "/users/" + course.teacher, - "ufora_id": course.ufora_id if course.ufora_id else "None", - } - response["data"] = data - return response, 200 class CourseForAdmins(Resource): @@ -461,7 +379,7 @@ def get(self, course_id): "Succesfully retrieved all admins of course " + str(course_id) ) response["data"] = admin_uids - response["url"] = abort_url # not actually aborting here tho heheh + response["url"] = abort_url return jsonify(admin_uids) def post(self, course_id): @@ -608,10 +526,14 @@ def delete(self, course_id): return response -courses_api.add_resource(CourseForUser, "/courses") +courses_bp.add_url_rule("/courses", + view_func=CourseForUser.as_view('course_endpoint')) -courses_api.add_resource(CourseByCourseId, "/courses/") +courses_bp.add_url_rule("/courses/", + view_func=CourseByCourseId.as_view('course_by_course_id')) -courses_api.add_resource(CourseForAdmins, "/courses//admins") +courses_bp.add_url_rule("/courses//admins", + view_func=CourseForAdmins.as_view('course_admins')) -courses_api.add_resource(CourseToAddStudents, "/courses//students") +courses_bp.add_url_rule("/courses//students", + view_func=CourseToAddStudents.as_view('course_students')) From a00cb993fa4a32dd9ed4ae2d9805f1dfae848980 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:55:17 +0100 Subject: [PATCH 039/144] split courses into multiple files to keep it organized --- backend/project/endpoints/courses.py | 539 ------------------ .../courses/course_admin_relation.py | 111 ++++ .../endpoints/courses/course_details.py | 111 ++++ .../courses/course_student_relation.py | 113 ++++ backend/project/endpoints/courses/courses.py | 51 ++ .../endpoints/courses/courses_config.py | 32 ++ .../endpoints/courses/courses_utils.py | 234 ++++++++ 7 files changed, 652 insertions(+), 539 deletions(-) delete mode 100644 backend/project/endpoints/courses.py create mode 100644 backend/project/endpoints/courses/course_admin_relation.py create mode 100644 backend/project/endpoints/courses/course_details.py create mode 100644 backend/project/endpoints/courses/course_student_relation.py create mode 100644 backend/project/endpoints/courses/courses.py create mode 100644 backend/project/endpoints/courses/courses_config.py create mode 100644 backend/project/endpoints/courses/courses_utils.py diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py deleted file mode 100644 index 4954eea0..00000000 --- a/backend/project/endpoints/courses.py +++ /dev/null @@ -1,539 +0,0 @@ -"""Course api point""" - -from os import getenv -from urllib.parse import urljoin - -from dotenv import load_dotenv - -from flask import Blueprint, jsonify, request -from flask import abort -from flask_restful import Api, Resource -from sqlalchemy.exc import SQLAlchemyError -from project.models.course_relations import CourseAdmin, CourseStudent -from project.models.users import User -from project.models.courses import Course -from project.utils.query_agent import query_selected_from_model, \ - insert_into_model,delete_by_id_from_model, \ - patch_by_id_from_model -from project import db - -courses_bp = Blueprint("courses", __name__) -courses_api = Api(courses_bp) - -load_dotenv() -API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") - -def execute_query_abort_if_db_error(query, url, query_all=False): - """ - Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. - If query_all == True, the query will be executed with the all() method, - otherwise with the first() method. - Args: - query (Query): The SQLAlchemy query to execute. - - Returns: - ResultProxy: The result of the query if successful, otherwise aborts with error 500. - """ - try: - if query_all: - result = query.all() - else: - result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - return result - - -def add_abort_if_error(to_add, url): - """ - Add a new object to the database - and handle any SQLAlchemyError that might occur. - - Args: - to_add (object): The object to add to the database. - """ - try: - db.session.add(to_add) - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def delete_abort_if_error(to_delete, url): - """ - Deletes the given object from the database - and aborts the request with a 500 error if a SQLAlchemyError occurs. - - Args: - - to_delete: The object to be deleted from the database. - """ - try: - db.session.delete(to_delete) - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def commit_abort_if_error(url): - """ - Commit the current session and handle any SQLAlchemyError that might occur. - """ - try: - db.session.commit() - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): - """ - Check if the current user is authorized to appoint new admins to a course. - - Args: - course_id (int): The ID of the course. - - Raises: - HTTPException: If the current user is not authorized or - if the UID of the person to be made an admin is missing in the request body. - """ - url = API_URL + "/courses/" + str(course_id) + "/admins" - abort_if_uid_is_none(teacher, url) - - course = get_course_abort_if_not_found(course_id) - - if teacher != course.teacher: - response = json_message("Only the teacher of a course can appoint new admins") - response["url"] = url - abort(403, description=response) - - if not assistant: - response = json_message( - "uid of person to make admin is required in the request body" - ) - response["url"] = url - abort(400, description=response) - - -def abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids -): - """ - Check the request to assign new students to a course. - - Args: - course_id (int): The ID of the course. - - Raises: - 403: If the user is not authorized to assign new students to the course. - 400: If the request body does not contain the required 'students' field. - """ - url = API_URL + "/courses/" + str(course_id) + "/students" - get_course_abort_if_not_found(course_id) - abort_if_no_user_found_for_uid(uid, url) - query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, url) - if not admin_relation: - message = "Not authorized to assign new students to course with id " + str( - course_id - ) - response = json_message(message) - response["url"] = url - abort(403, description=response) - - if not student_uids: - message = """To assign new students to a course, - you should have a students field with a list of uids in the request body""" - response = json_message(message) - response["url"] = url - abort(400, description=response) - - -def abort_if_uid_is_none(uid, url): - """ - Check whether the uid is None if so - abort with error 400 - """ - if uid is None: - response = json_message("There should be a uid in the request query") - response["url"] = url - abort(400, description=response) - - -def abort_if_no_user_found_for_uid(uid, url): - """ - Check if a user exists based on the provided uid. - - Args: - uid (int): The unique identifier of the user. - - Raises: - NotFound: If the user with the given uid is not found. - """ - query = User.query.filter_by(uid=uid) - user = execute_query_abort_if_db_error(query, url) - - if not user: - response = json_message("User with uid " + uid + " was not found") - response["url"] = url - abort(404, description=response) - return user - - -def get_admin_relation(uid, course_id): - """ - Retrieve the CourseAdmin object for the given uid and course. - - Args: - uid (int): The user ID. - course_id (int): The course ID. - - Returns: - CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. - """ - return execute_query_abort_if_db_error( - CourseAdmin.query.filter_by(uid=uid, course_id=course_id), - url=API_URL + "/courses/" + str(course_id) + "/admins", - ) - - -def json_message(message): - """ - Create a json message with the given message. - - Args: - message (str): The message to include in the json. - - Returns: - dict: The message in a json format. - """ - return {"message": message} - - -def get_course_abort_if_not_found(course_id): - """ - Get a course by its ID. - - Args: - course_id (int): The course ID. - - Returns: - Course: The course with the given ID. - """ - query = Course.query.filter_by(course_id=course_id) - course = execute_query_abort_if_db_error(query, API_URL + "/courses") - - if not course: - response = json_message("Course not found") - response["url"] = API_URL + "/courses" - abort(404, description=response) - - return course - -class CourseForUser(Resource): - """Api endpoint for the /courses link""" - - def get(self): - """ " - Get function for /courses this will be the main endpoint - to get all courses and filter by given query parameter like /courses?parameter=... - parameters can be either one of the following: teacher,ufora_id,name. - """ - - return query_selected_from_model( - Course, - RESPONSE_URL, - url_mapper={"course_id": RESPONSE_URL}, - filters=request.args - ) - - def post(self): - """ - This function will create a new course - if the body of the post contains a name and uid is an admin or teacher - """ - - return insert_into_model( - Course, - request.json, - RESPONSE_URL, - "course_id", - required_fields=["name", "teacher"] - ) - - -class CourseByCourseId(Resource): - """Api endpoint for the /courses/course_id link""" - - def get(self, course_id): - """ - This get function will return all the related projects of the course - in the following form: - { - course: course with course_id - projects: [ - list of all projects that have course_id - where projects are jsons containing the title, deadline and project_id - ] - } - """ - try: - course_details = db.session.query( - Course.course_id, - Course.name, - Course.ufora_id, - Course.teacher - ).filter( - Course.course_id == course_id).first() - - if not course_details: - return { - "message": "Course not found", - "url": RESPONSE_URL - }, 404 - - admins = db.session.query(CourseAdmin.uid).filter( - CourseAdmin.course_id == course_id - ).all() - - students = db.session.query(CourseStudent.uid).filter( - CourseStudent.course_id == course_id - ).all() - - user_url = urljoin(API_URL + "/", "users") - - admin_ids = [ urljoin(user_url + "/" , admin[0]) for admin in admins] - student_ids = [ urljoin(user_url + "/", student[0]) for student in students] - - result = { - 'course_id': course_details.course_id, - 'name': course_details.name, - 'ufora_id': course_details.ufora_id, - 'teacher': course_details.teacher, - 'admins': admin_ids, - 'students': student_ids - } - - return { - "message": "Succesfully retrieved course with course_id: " + str(course_id), - "data": result, - "url": urljoin(RESPONSE_URL + "/", str(course_id)) - } - except SQLAlchemyError: - return { - "error": "Something went wrong while querying the database.", - "url": RESPONSE_URL}, 500 - - def delete(self, course_id): - """ - This function will delete the course with course_id - """ - return delete_by_id_from_model( - Course, - "course_id", - course_id, - RESPONSE_URL - ) - - def patch(self, course_id): - """ - This function will update the course with course_id - """ - - return patch_by_id_from_model( - Course, - "course_id", - course_id, - RESPONSE_URL, - request.json - ) - - -class CourseForAdmins(Resource): - """ - This class will handle post and delete queries to - the /courses/course_id/admins url, only the teacher of a course can do this - """ - - def get(self, course_id): - """ - This function will return all the admins of a course - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - get_course_abort_if_not_found(course_id) - - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + a.uid - for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all admins of course " + str(course_id) - ) - response["data"] = admin_uids - response["url"] = abort_url - return jsonify(admin_uids) - - def post(self, course_id): - """ - Api endpoint for adding new admins to a course, can only be done by the teacher - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - teacher = request.args.get("uid") - data = request.get_json() - assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - - query = User.query.filter_by(uid=assistant) - new_admin = execute_query_abort_if_db_error(query, abort_url) - if not new_admin: - message = ( - "User to make admin was not found, please request with a valid uid" - ) - return json_message(message), 404 - - admin_relation = CourseAdmin(uid=assistant, course_id=course_id) - add_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - response = json_message( - f"Admin assistant added to course {course_id}" - ) - response["url"] = abort_url - data = { - "course_id": API_URL + "/courses/" + str(course_id), - "uid": API_URL + "/users/" + assistant, - } - response["data"] = data - return response, 201 - - def delete(self, course_id): - """ - Api endpoint for removing admins of a course, can only be done by the teacher - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - teacher = request.args.get("uid") - data = request.get_json() - assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - - query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, abort_url) - if not admin_relation: - message = "Course with given admin not found" - return json_message(message), 404 - - delete_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - - message = ( - f"Admin {assistant}" - f" was succesfully removed from course {course_id}" - ) - response = json_message(message) - response["url"] = abort_url - return response, 204 - - -class CourseToAddStudents(Resource): - """ - Class that will respond to the /courses/course_id/students link - teachers should be able to assign and remove students from courses, - and everyone should be able to list all students assigned to a course - """ - - def get(self, course_id): - """ - Get function at /courses/course_id/students - to get all the users assigned to a course - everyone can get this data so no need to have uid query in the link - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - get_course_abort_if_not_found(course_id) - - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + s.uid - for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all students of course " + str(course_id) - ) - response["data"] = student_uids - response["url"] = abort_url - return response - - def post(self, course_id): - """ - Allows admins of a course to assign new students by posting to: - /courses/course_id/students with a list of uid in the request body under key "students" - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - uid = request.args.get("uid") - data = request.get_json() - student_uids = data.get("students") - abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids - ) - - for uid in student_uids: - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student_relation = execute_query_abort_if_db_error(query, abort_url) - if student_relation: - db.session.rollback() - message = ( - "Student with uid " + uid + " is already assigned to the course" - ) - return json_message(message), 400 - add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) - commit_abort_if_error(abort_url) - response = json_message("User were succesfully added to the course") - response["url"] = abort_url - data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} - response["data"] = data - return response, 201 - - def delete(self, course_id): - """ - This function allows admins of a course to remove students by sending a delete request to - /courses/course_id/students with inside the request body - a field "students" = [list of uids to unassign] - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - uid = request.args.get("uid") - data = request.get_json() - student_uids = data.get("students") - abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids - ) - - for uid in student_uids: - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student_relation = execute_query_abort_if_db_error(query, abort_url) - if student_relation: - delete_abort_if_error(student_relation, abort_url) - commit_abort_if_error(abort_url) - - response = json_message("User were succesfully removed from the course") - response["url"] = API_URL + "/courses/" + str(course_id) + "/students" - return response - - -courses_bp.add_url_rule("/courses", - view_func=CourseForUser.as_view('course_endpoint')) - -courses_bp.add_url_rule("/courses/", - view_func=CourseByCourseId.as_view('course_by_course_id')) - -courses_bp.add_url_rule("/courses//admins", - view_func=CourseForAdmins.as_view('course_admins')) - -courses_bp.add_url_rule("/courses//students", - view_func=CourseToAddStudents.as_view('course_students')) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py new file mode 100644 index 00000000..e4bc4b4e --- /dev/null +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -0,0 +1,111 @@ +""" +This module will handle the /courses//admins endpoint +It will allow the teacher of a course to add and remove admins from a course +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask import jsonify, request +from flask_restful import Resource + +from project.models.course_relations import CourseAdmin +from project.models.users import User +from project.endpoints.courses.courses_utils import ( + execute_query_abort_if_db_error, + add_abort_if_error, + commit_abort_if_error, + delete_abort_if_error, + get_course_abort_if_not_found, + abort_if_not_teacher_or_none_assistant, + json_message +) + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseForAdmins(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/admins url, only the teacher of a course can do this + """ + + def get(self, course_id): + """ + This function will return all the admins of a course + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + get_course_abort_if_not_found(course_id) + + query = CourseAdmin.query.filter_by(course_id=course_id) + admin_uids = [ + API_URL + "/users/" + a.uid + for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) + ] + response = json_message( + "Succesfully retrieved all admins of course " + str(course_id) + ) + response["data"] = admin_uids + response["url"] = abort_url + return jsonify(admin_uids) + + def post(self, course_id): + """ + Api endpoint for adding new admins to a course, can only be done by the teacher + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = User.query.filter_by(uid=assistant) + new_admin = execute_query_abort_if_db_error(query, abort_url) + if not new_admin: + message = ( + "User to make admin was not found, please request with a valid uid" + ) + return json_message(message), 404 + + admin_relation = CourseAdmin(uid=assistant, course_id=course_id) + add_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + response = json_message( + f"Admin assistant added to course {course_id}" + ) + response["url"] = abort_url + data = { + "course_id": API_URL + "/courses/" + str(course_id), + "uid": API_URL + "/users/" + assistant, + } + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + Api endpoint for removing admins of a course, can only be done by the teacher + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, abort_url) + if not admin_relation: + message = "Course with given admin not found" + return json_message(message), 404 + + delete_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + + message = ( + f"Admin {assistant}" + f" was succesfully removed from course {course_id}" + ) + response = json_message(message) + response["url"] = abort_url + return response, 204 diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py new file mode 100644 index 00000000..d7ac7c37 --- /dev/null +++ b/backend/project/endpoints/courses/course_details.py @@ -0,0 +1,111 @@ +""" +This file contains the api endpoint for the /courses/course_id url +This file is responsible for handling the requests made to the /courses/course_id url +and returning the appropriate response as well as handling the requests made to the +/courses/course_id/admins and /courses/course_id/students urls +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource +from sqlalchemy.exc import SQLAlchemyError + +from project.models.courses import Course +from project.models.course_relations import CourseAdmin, CourseStudent + +from project import db +from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseByCourseId(Resource): + """Api endpoint for the /courses/course_id link""" + + def get(self, course_id): + """ + This get function will return all the related projects of the course + in the following form: + { + course: course with course_id + projects: [ + list of all projects that have course_id + where projects are jsons containing the title, deadline and project_id + ] + } + """ + try: + course_details = db.session.query( + Course.course_id, + Course.name, + Course.ufora_id, + Course.teacher + ).filter( + Course.course_id == course_id).first() + + if not course_details: + return { + "message": "Course not found", + "url": RESPONSE_URL + }, 404 + + admins = db.session.query(CourseAdmin.uid).filter( + CourseAdmin.course_id == course_id + ).all() + + students = db.session.query(CourseStudent.uid).filter( + CourseStudent.course_id == course_id + ).all() + + user_url = urljoin(API_URL + "/", "users") + + admin_ids = [ urljoin(user_url + "/" , admin[0]) for admin in admins] + student_ids = [ urljoin(user_url + "/", student[0]) for student in students] + + result = { + 'course_id': course_details.course_id, + 'name': course_details.name, + 'ufora_id': course_details.ufora_id, + 'teacher': course_details.teacher, + 'admins': admin_ids, + 'students': student_ids + } + + return { + "message": "Succesfully retrieved course with course_id: " + str(course_id), + "data": result, + "url": urljoin(RESPONSE_URL + "/", str(course_id)) + } + except SQLAlchemyError: + return { + "error": "Something went wrong while querying the database.", + "url": RESPONSE_URL}, 500 + + def delete(self, course_id): + """ + This function will delete the course with course_id + """ + return delete_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL + ) + + def patch(self, course_id): + """ + This function will update the course with course_id + """ + + return patch_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL, + request.json + ) diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py new file mode 100644 index 00000000..3958422f --- /dev/null +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -0,0 +1,113 @@ +""" +This file contains the class CourseToAddStudents which is a +resource for the /courses/course_id/students link. +This class will allow admins of a course to assign and remove students from courses, +and everyone should be able to list all students assigned to a course. +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project import db +from project.models.course_relations import CourseStudent +from project.endpoints.courses.courses_utils import ( + execute_query_abort_if_db_error, + add_abort_if_error, + commit_abort_if_error, + delete_abort_if_error, + get_course_abort_if_not_found, + abort_if_none_uid_student_uids_or_non_existant_course_id, + json_message, +) + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseToAddStudents(Resource): + """ + Class that will respond to the /courses/course_id/students link + teachers should be able to assign and remove students from courses, + and everyone should be able to list all students assigned to a course + """ + + def get(self, course_id): + """ + Get function at /courses/course_id/students + to get all the users assigned to a course + everyone can get this data so no need to have uid query in the link + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + get_course_abort_if_not_found(course_id) + + query = CourseStudent.query.filter_by(course_id=course_id) + student_uids = [ + API_URL + "/users/" + s.uid + for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) + ] + response = json_message( + "Succesfully retrieved all students of course " + str(course_id) + ) + response["data"] = student_uids + response["url"] = abort_url + return response + + def post(self, course_id): + """ + Allows admins of a course to assign new students by posting to: + /courses/course_id/students with a list of uid in the request body under key "students" + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + db.session.rollback() + message = ( + "Student with uid " + uid + " is already assigned to the course" + ) + return json_message(message), 400 + add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) + commit_abort_if_error(abort_url) + response = json_message("User were succesfully added to the course") + response["url"] = abort_url + data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + This function allows admins of a course to remove students by sending a delete request to + /courses/course_id/students with inside the request body + a field "students" = [list of uids to unassign] + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + delete_abort_if_error(student_relation, abort_url) + commit_abort_if_error(abort_url) + + response = json_message("User were succesfully removed from the course") + response["url"] = API_URL + "/courses/" + str(course_id) + "/students" + return response diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py new file mode 100644 index 00000000..c06d7dfc --- /dev/null +++ b/backend/project/endpoints/courses/courses.py @@ -0,0 +1,51 @@ +""" +This file contains the main endpoint for the /courses url. +This endpoint is used to get all courses and filter by given +query parameter like /courses?parameter=... +parameters can be either one of the following: teacher,ufora_id,name. +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project.models.courses import Course +from project.utils.query_agent import query_selected_from_model, insert_into_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseForUser(Resource): + """Api endpoint for the /courses link""" + + def get(self): + """ " + Get function for /courses this will be the main endpoint + to get all courses and filter by given query parameter like /courses?parameter=... + parameters can be either one of the following: teacher,ufora_id,name. + """ + + return query_selected_from_model( + Course, + RESPONSE_URL, + url_mapper={"course_id": RESPONSE_URL}, + filters=request.args + ) + + def post(self): + """ + This function will create a new course + if the body of the post contains a name and uid is an admin or teacher + """ + + return insert_into_model( + Course, + request.json, + RESPONSE_URL, + "course_id", + required_fields=["name", "teacher"] + ) diff --git a/backend/project/endpoints/courses/courses_config.py b/backend/project/endpoints/courses/courses_config.py new file mode 100644 index 00000000..f791031f --- /dev/null +++ b/backend/project/endpoints/courses/courses_config.py @@ -0,0 +1,32 @@ +""" +This file is used to configure the courses blueprint and the courses api. +It is used to define the routes for the courses blueprint and the +corresponding api endpoints. + +The courses blueprint is used to define the routes for the courses api +endpoints and the courses api is used to define the routes for the courses +api endpoints. +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.courses.courses import CourseForUser +from project.endpoints.courses.course_details import CourseByCourseId +from project.endpoints.courses.course_admin_relation import CourseForAdmins +from project.endpoints.courses.course_student_relation import CourseToAddStudents + +courses_bp = Blueprint("courses", __name__) +courses_api = Api(courses_bp) + +courses_bp.add_url_rule("/courses", + view_func=CourseForUser.as_view('course_endpoint')) + +courses_bp.add_url_rule("/courses/", + view_func=CourseByCourseId.as_view('course_by_course_id')) + +courses_bp.add_url_rule("/courses//admins", + view_func=CourseForAdmins.as_view('course_admins')) + +courses_bp.add_url_rule("/courses//students", + view_func=CourseToAddStudents.as_view('course_students')) diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py new file mode 100644 index 00000000..5543db33 --- /dev/null +++ b/backend/project/endpoints/courses/courses_utils.py @@ -0,0 +1,234 @@ +""" +This module contains utility functions for the courses endpoints. +The functions are used to interact with the database and handle errors. +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv +from flask import abort +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.course_relations import CourseAdmin +from project.models.users import User +from project.models.courses import Course + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +def execute_query_abort_if_db_error(query, url, query_all=False): + """ + Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. + If query_all == True, the query will be executed with the all() method, + otherwise with the first() method. + Args: + query (Query): The SQLAlchemy query to execute. + + Returns: + ResultProxy: The result of the query if successful, otherwise aborts with error 500. + """ + try: + if query_all: + result = query.all() + else: + result = query.first() + except SQLAlchemyError as e: + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + return result + + +def add_abort_if_error(to_add, url): + """ + Add a new object to the database + and handle any SQLAlchemyError that might occur. + + Args: + to_add (object): The object to add to the database. + """ + try: + db.session.add(to_add) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def delete_abort_if_error(to_delete, url): + """ + Deletes the given object from the database + and aborts the request with a 500 error if a SQLAlchemyError occurs. + + Args: + - to_delete: The object to be deleted from the database. + """ + try: + db.session.delete(to_delete) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def commit_abort_if_error(url): + """ + Commit the current session and handle any SQLAlchemyError that might occur. + """ + try: + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): + """ + Check if the current user is authorized to appoint new admins to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + HTTPException: If the current user is not authorized or + if the UID of the person to be made an admin is missing in the request body. + """ + url = API_URL + "/courses/" + str(course_id) + "/admins" + abort_if_uid_is_none(teacher, url) + + course = get_course_abort_if_not_found(course_id) + + if teacher != course.teacher: + response = json_message("Only the teacher of a course can appoint new admins") + response["url"] = url + abort(403, description=response) + + if not assistant: + response = json_message( + "uid of person to make admin is required in the request body" + ) + response["url"] = url + abort(400, description=response) + + +def abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids +): + """ + Check the request to assign new students to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + 403: If the user is not authorized to assign new students to the course. + 400: If the request body does not contain the required 'students' field. + """ + url = API_URL + "/courses/" + str(course_id) + "/students" + get_course_abort_if_not_found(course_id) + abort_if_no_user_found_for_uid(uid, url) + query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, url) + if not admin_relation: + message = "Not authorized to assign new students to course with id " + str( + course_id + ) + response = json_message(message) + response["url"] = url + abort(403, description=response) + + if not student_uids: + message = """To assign new students to a course, + you should have a students field with a list of uids in the request body""" + response = json_message(message) + response["url"] = url + abort(400, description=response) + + +def abort_if_uid_is_none(uid, url): + """ + Check whether the uid is None if so + abort with error 400 + """ + if uid is None: + response = json_message("There should be a uid in the request query") + response["url"] = url + abort(400, description=response) + + +def abort_if_no_user_found_for_uid(uid, url): + """ + Check if a user exists based on the provided uid. + + Args: + uid (int): The unique identifier of the user. + + Raises: + NotFound: If the user with the given uid is not found. + """ + query = User.query.filter_by(uid=uid) + user = execute_query_abort_if_db_error(query, url) + + if not user: + response = json_message("User with uid " + uid + " was not found") + response["url"] = url + abort(404, description=response) + return user + + +def get_admin_relation(uid, course_id): + """ + Retrieve the CourseAdmin object for the given uid and course. + + Args: + uid (int): The user ID. + course_id (int): The course ID. + + Returns: + CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. + """ + return execute_query_abort_if_db_error( + CourseAdmin.query.filter_by(uid=uid, course_id=course_id), + url=API_URL + "/courses/" + str(course_id) + "/admins", + ) + + +def json_message(message): + """ + Create a json message with the given message. + + Args: + message (str): The message to include in the json. + + Returns: + dict: The message in a json format. + """ + return {"message": message} + + +def get_course_abort_if_not_found(course_id): + """ + Get a course by its ID. + + Args: + course_id (int): The course ID. + + Returns: + Course: The course with the given ID. + """ + query = Course.query.filter_by(course_id=course_id) + course = execute_query_abort_if_db_error(query, API_URL + "/courses") + + if not course: + response = json_message("Course not found") + response["url"] = API_URL + "/courses" + abort(404, description=response) + + return course From f4aff02310b1bff882eb26b0ecfedb90c8a4d249 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:55:56 +0100 Subject: [PATCH 040/144] fixed linting --- backend/project/utils/misc.py | 14 +++++++++++--- backend/project/utils/query_agent.py | 13 +++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 9d313467..7fe39a8c 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -22,8 +22,6 @@ def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[st for key, value in data.items(): if key in url_mapper: data[key] = urljoin(url_mapper[key] + "/", str(value)) - print(url_mapper) - print(data) return data def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): @@ -65,4 +63,14 @@ def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): - return {key: value for key, value in data.items() if hasattr(model, key)} \ No newline at end of file + """ + Filters the data to only contain the fields of the model. + + Args: + model: DeclarativeMeta - The model to filter the data with. + data: Dict[str, str] - The data to filter. + + Returns: + A dictionary with the fields of the model. + """ + return {key: value for key, value in data.items() if hasattr(model, key)} diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index d0595afd..bbbcf118 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -7,7 +7,7 @@ from typing import Dict, List, Union from urllib.parse import urljoin from flask import jsonify -from sqlalchemy import and_, inspect +from sqlalchemy import and_ from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError @@ -54,7 +54,7 @@ def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, url_id_field: str, - required_fields: List[str] = []): + required_fields: List[str] = None): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -69,13 +69,15 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: + if required_fields is None: + required_fields = [] # Check if all non-nullable fields are present in the data missing_fields = [field for field in required_fields if field not in data] - + if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", "url": response_url_base}, 400 - + filtered_data = filter_model_fields(model, data) new_instance: DeclarativeMeta = model(**filtered_data) db.session.add(new_instance) @@ -84,7 +86,7 @@ def insert_into_model(model: DeclarativeMeta, "data": new_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError as e: + except SQLAlchemyError: return jsonify({"error": "Something went wrong while inserting into the database.", "url": response_url_base}), 500 @@ -162,7 +164,6 @@ def query_by_id_from_model(model: DeclarativeMeta, result: Query = model.query.filter(getattr(model, column_name) == column_id).first() if not result: return {"message": "Resource not found", "url": base_url}, 404 - print(column_id) return jsonify({ "data": result, "message": "Resource fetched correctly", From 4e0945f7ee3d49ba912dc88ae9c221a4f31d1a63 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:57:15 +0100 Subject: [PATCH 041/144] added new courses blueprint --- backend/project/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 33450700..b0c21275 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -6,8 +6,7 @@ from .db_in import db from .endpoints.index.index import index_bp from .endpoints.projects.project_endpoint import project_bp -from .endpoints.courses import courses_bp - +from .endpoints.courses.courses_config import courses_bp def create_app(): From 05038c97d3f57d3cbb8e85cac92d37d1c8feb433 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 22:14:52 +0100 Subject: [PATCH 042/144] removed trailing space --- backend/tests/endpoints/courses_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 9beb64fe..0478007b 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -74,7 +74,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client assert response.status_code == 400 sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - + course_admins = [ s.uid for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() From edb50d5671a21202109832f50e3b37d0394bf8b3 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:29:45 +0100 Subject: [PATCH 043/144] #15 - Trying to fix the github tests --- backend/tests/endpoints/conftest.py | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 91a767aa..fc40b1a5 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -28,32 +28,37 @@ def users(): def courses(): """Return a list of courses to populate the database""" return [ - Course(course_id=1, name="AD3", teacher="brinkmann"), - Course(course_id=2, name="RAF", teacher="laermans"), + Course(name="AD3", teacher="brinkmann", autoincrement=True), + Course(name="RAF", teacher="laermans"), ] @pytest.fixture -def course_relations(): +def course_relations(courses): """Returns a list of course relations to populate the database""" + course_id_ad3 = courses[0].course_id + course_id_raf = courses[1].course_id + return [ - CourseAdmin(course_id=1, uid="brinkmann"), - CourseStudent(course_id=1, uid="student01"), - CourseStudent(course_id=1, uid="student02"), - CourseAdmin(course_id=2, uid="laermans"), - CourseStudent(course_id=2, uid="student02") + CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), + CourseStudent(course_id=course_id_ad3, uid="student01"), + CourseStudent(course_id=course_id_ad3, uid="student02"), + CourseAdmin(course_id=course_id_raf, uid="laermans"), + CourseStudent(course_id=course_id_raf, uid="student02") ] @pytest.fixture -def projects(): +def projects(courses): """Return a list of projects to populate the database""" + course_id_ad3 = courses[0].course_id + course_id_raf = courses[1].course_id + return [ Project( - project_id=1, title="B+ Trees", descriptions="Implement B+ trees", assignment_file="assignement.pdf", deadline=datetime(2024,3,15,13,0,0), - course_id=1, + course_id=course_id_ad3, visible_for_students=True, archieved=False, test_path="/tests", @@ -61,12 +66,11 @@ def projects(): regex_expressions=["*"] ), Project( - project_id=2, title="Predicaten", descriptions="Predicaten project", assignment_file="assignment.pdf", deadline=datetime(2023,3,15,13,0,0), - course_id=2, + course_id=course_id_raf, visible_for_students=False, archieved=True, test_path="/tests", @@ -76,30 +80,30 @@ def projects(): ] @pytest.fixture -def submissions(): +def submissions(projects): """Return a list of submissions to populate the database""" + project_id_ad3 = projects[0].project_id + project_id_raf = projects[1].project_id + return [ Submission( - submission_id=1, uid="student01", - project_id=1, + project_id=project_id_ad3, grading=16, submission_time=datetime(2024,3,14,12,0,0), submission_path="/submissions/1", submission_status=True ), Submission( - submission_id=2, uid="student02", - project_id=1, + project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59), submission_path="/submissions/2", submission_status=False ), Submission( - submission_id=3, uid="student02", - project_id=2, + project_id=project_id_raf, grading=15, submission_time=datetime(2023,3,5,10,0,0), submission_path="/submissions/3", From c0dbcd71d86935ad11684c859bc2cfc6ce8ac090 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:34:49 +0100 Subject: [PATCH 044/144] #15 - Trying to fix the github tests 2 --- backend/tests/endpoints/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index fc40b1a5..8943136c 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -28,7 +28,7 @@ def users(): def courses(): """Return a list of courses to populate the database""" return [ - Course(name="AD3", teacher="brinkmann", autoincrement=True), + Course(name="AD3", teacher="brinkmann"), Course(name="RAF", teacher="laermans"), ] From a142a88d037dec1b355cb49f165bde6e22a1d4c7 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Thu, 7 Mar 2024 09:24:24 +0100 Subject: [PATCH 045/144] changed test to stay consistent with course admin relation also --- backend/tests/endpoints/courses_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 0478007b..ac299f01 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -113,7 +113,7 @@ def test_get_courses(self, courses_get_db, client, api_url): assert response.status_code == 200 sel2_students = [ - f"{api_url}/users/" + s.uid + {"uid": f"{api_url}/users/" + s.uid} for s in CourseStudent.query.filter_by(course_id=course.course_id).all() ] From 1f11956b43536811fa59dd6a658c0c00bc05a998 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Thu, 7 Mar 2024 09:25:16 +0100 Subject: [PATCH 046/144] added query agent functions to prevent code duplication --- .../courses/course_admin_relation.py | 37 +++++++------------ .../courses/course_student_relation.py | 18 ++++----- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index e4bc4b4e..229694f7 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,20 +7,20 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import jsonify, request +from flask import request from flask_restful import Resource from project.models.course_relations import CourseAdmin from project.models.users import User from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, - add_abort_if_error, commit_abort_if_error, delete_abort_if_error, get_course_abort_if_not_found, abort_if_not_teacher_or_none_assistant, json_message ) +from project.utils.query_agent import query_selected_from_model, insert_into_model load_dotenv() API_URL = getenv("API_HOST") @@ -39,17 +39,13 @@ def get(self, course_id): abort_url = API_URL + "/courses/" + str(course_id) + "/admins" get_course_abort_if_not_found(course_id) - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + a.uid - for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all admins of course " + str(course_id) + return query_selected_from_model( + CourseAdmin, + abort_url, + select_values=["uid"], + url_mapper={"uid": urljoin(API_URL + "/", "users")}, + filters={"course_id": course_id}, ) - response["data"] = admin_uids - response["url"] = abort_url - return jsonify(admin_uids) def post(self, course_id): """ @@ -69,19 +65,12 @@ def post(self, course_id): ) return json_message(message), 404 - admin_relation = CourseAdmin(uid=assistant, course_id=course_id) - add_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - response = json_message( - f"Admin assistant added to course {course_id}" + return insert_into_model( + CourseAdmin, + {"uid": assistant, "course_id": course_id}, + abort_url, + "uid" ) - response["url"] = abort_url - data = { - "course_id": API_URL + "/courses/" + str(course_id), - "uid": API_URL + "/users/" + assistant, - } - response["data"] = data - return response, 201 def delete(self, course_id): """ diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 3958422f..a5488e47 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -25,6 +25,8 @@ json_message, ) +from project.utils.query_agent import query_selected_from_model + load_dotenv() API_URL = getenv("API_HOST") RESPONSE_URL = urljoin(API_URL + "/", "courses") @@ -45,17 +47,13 @@ def get(self, course_id): abort_url = API_URL + "/courses/" + str(course_id) + "/students" get_course_abort_if_not_found(course_id) - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + s.uid - for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all students of course " + str(course_id) + return query_selected_from_model( + CourseStudent, + abort_url, + select_values=["uid"], + url_mapper={"uid": urljoin(API_URL + "/", "users")}, + filters={"course_id": course_id} ) - response["data"] = student_uids - response["url"] = abort_url - return response def post(self, course_id): """ From 7fad876efea2652296f831e3e1561e623952c679 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 7 Mar 2024 10:27:10 +0100 Subject: [PATCH 047/144] first version of file uploads --- backend/project/__main__.py | 2 ++ backend/project/endpoints/projects/endpoint_parser.py | 4 +++- backend/project/endpoints/projects/project_endpoint.py | 1 - backend/project/endpoints/projects/projects.py | 10 +++++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index a4bd122b..32547c6e 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,5 +1,7 @@ """Main entry point for the application.""" +from sys import path +path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 87f61e69..99452929 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -3,11 +3,12 @@ """ from flask_restful import reqparse +import werkzeug parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title') parser.add_argument('descriptions', type=str, help='Projects description') -parser.add_argument('assignment_file', type=str, help='Projects assignment file') +parser.add_argument('assignment_file', type=werkzeug.datastructures.FileStorage, help='Projects assignment file', location="form") parser.add_argument("deadline", type=str, help='Projects deadline') parser.add_argument("course_id", type=str, help='Projects course_id') parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students') @@ -23,6 +24,7 @@ def parse_project_params(): """ args = parser.parse_args() result_dict = {} + print(args) for key, value in args.items(): if value is not None: diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index c996a514..eef5b34d 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -10,7 +10,6 @@ from project.endpoints.projects.project_detail import ProjectDetail project_bp = Blueprint('project_endpoint', __name__) -project_endpoint = Api(project_bp) project_bp.add_url_rule( '/projects', diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 68600034..a72957ba 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -1,6 +1,7 @@ """ Module that implements the /projects endpoint of the API """ +import os from os import getenv from urllib.parse import urljoin @@ -11,6 +12,8 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model API_URL = getenv('API_HOST') +UPLOAD_FOLDER = '/project/endpoints/uploads/' +ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} class ProjectsEndpoint(Resource): """ @@ -40,4 +43,9 @@ def post(self): using flask_restfull parse lib """ - return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) + file = request.files["assignment_file"] + + # save the file that is given with the request + file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + # return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) + return {}, 200 From ca42936bb517e64a7bd14a1c392260faa5a0d719 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 7 Mar 2024 12:34:31 +0100 Subject: [PATCH 048/144] formatting json for posting in the db --- .../project/endpoints/projects/projects.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index a72957ba..ab3a513a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -12,8 +12,12 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model API_URL = getenv('API_HOST') -UPLOAD_FOLDER = '/project/endpoints/uploads/' -ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} +UPLOAD_FOLDER = getenv('UPLOAD_URL') +ALLOWED_EXTENSIONS = {'zip'} + +def allowed_file(filename: str): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS class ProjectsEndpoint(Resource): """ @@ -46,6 +50,18 @@ def post(self): file = request.files["assignment_file"] # save the file that is given with the request - file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + if allowed_file(file.filename): + file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + else: + print("no zip file given") # return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) - return {}, 200 + print(request.form) + project_json = {} + for key, value in request.form.items(): + print("key: {}, value: {}".format(key, value)) + project_json[key] = value + print(project_json) + new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects")) + print(new_project) + + return new_project From 8d75ae6c05269d2fca1353154f7c291d17a0f665 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 7 Mar 2024 13:01:48 +0100 Subject: [PATCH 049/144] working file upload system, reused parser --- .../endpoints/projects/endpoint_parser.py | 18 ++++++------ .../project/endpoints/projects/projects.py | 28 +++++++++++++------ backend/project/utils/query_agent.py | 4 ++- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 99452929..d5ece633 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -6,16 +6,16 @@ import werkzeug parser = reqparse.RequestParser() -parser.add_argument('title', type=str, help='Projects title') -parser.add_argument('descriptions', type=str, help='Projects description') +parser.add_argument('title', type=str, help='Projects title', location="form") +parser.add_argument('descriptions', type=str, help='Projects description', location="form") parser.add_argument('assignment_file', type=werkzeug.datastructures.FileStorage, help='Projects assignment file', location="form") -parser.add_argument("deadline", type=str, help='Projects deadline') -parser.add_argument("course_id", type=str, help='Projects course_id') -parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students') -parser.add_argument("archieved", type=bool, help='Projects') -parser.add_argument("test_path", type=str, help='Projects test path') -parser.add_argument("script_name", type=str, help='Projects test script path') -parser.add_argument("regex_expressions", type=str, help='Projects regex expressions') +parser.add_argument("deadline", type=str, help='Projects deadline', location="form") +parser.add_argument("course_id", type=str, help='Projects course_id', location="form") +parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students', location="form") +parser.add_argument("archieved", type=bool, help='Projects', location="form") +parser.add_argument("test_path", type=str, help='Projects test path', location="form") +parser.add_argument("script_name", type=str, help='Projects test script path', location="form") +parser.add_argument("regex_expressions", type=str, help='Projects regex expressions', location="form") def parse_project_params(): diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ab3a513a..af303cc9 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -11,10 +11,24 @@ from project.models.projects import Project from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.endpoints.projects.endpoint_parser import parse_project_params + API_URL = getenv('API_HOST') UPLOAD_FOLDER = getenv('UPLOAD_URL') ALLOWED_EXTENSIONS = {'zip'} +def parse_immutabledict(request): + output_json = {} + for key, value in request.form.items(): + if value == "false": + print("false") + output_json[key] = False + if value == "true": + output_json[key] = True + else: + output_json[key] = value + return output_json + def allowed_file(filename: str): return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @@ -48,20 +62,16 @@ def post(self): """ file = request.files["assignment_file"] + project_json = parse_project_params() + print("args") + print(arg) # save the file that is given with the request if allowed_file(file.filename): file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) else: print("no zip file given") - # return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) - print(request.form) - project_json = {} - for key, value in request.form.items(): - print("key: {}, value: {}".format(key, value)) - project_json[key] = value - print(project_json) - new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects")) - print(new_project) + + new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects"), "project_id", required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"]) return new_project diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index bbbcf118..24e857e2 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -86,7 +86,9 @@ def insert_into_model(model: DeclarativeMeta, "data": new_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError: + except SQLAlchemyError as e: + print("error") + print(e) return jsonify({"error": "Something went wrong while inserting into the database.", "url": response_url_base}), 500 From acbb1b25dceb6facfc472ebf71bcc8e9022a6ef6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 7 Mar 2024 18:11:54 +0100 Subject: [PATCH 050/144] #15 - Changing responses --- .../endpoints/index/OpenAPI_Object.json | 165 ++++++------------ backend/project/endpoints/submissions.py | 60 ++++--- backend/project/models/submissions.py | 6 +- backend/tests/endpoints/conftest.py | 9 +- backend/tests/endpoints/submissions_test.py | 72 ++------ 5 files changed, 115 insertions(+), 197 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 5469ec2d..3b61672a 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1370,10 +1370,6 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" }, @@ -1399,16 +1395,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1422,16 +1410,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1475,19 +1455,39 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "url": { "type": "string", "format": "uri" }, - "message": { - "type": "string" - }, "data": { "type": "object", "properties": { - "submission": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { "type": "string", "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } @@ -1503,16 +1503,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1526,16 +1518,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1556,10 +1540,6 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" }, @@ -1610,16 +1590,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1633,16 +1605,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1678,16 +1642,41 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "url": { "type": "string", "format": "uri" }, - "message": { - "type": "string" - }, "data": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" + } + } } } } @@ -1701,16 +1690,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1724,16 +1705,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1747,16 +1720,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1775,16 +1740,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1798,16 +1755,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1821,16 +1770,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 28c74ce7..b31ca529 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -26,11 +26,7 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - data = { - "url": f"{API_HOST}/submissions", - "message": "Successfully fetched the submissions", - "data": {} - } + data = {} try: with db.session() as session: query = session.query(Submission) @@ -53,7 +49,8 @@ def get(self) -> dict[str, any]: query = query.filter_by(project_id=int(project_id)) # Get the submissions - data["data"]["submissions"] = [ + data["message"] = "Successfully fetched the submissions" + data["data"] = [ f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() ] return data, 200 @@ -69,11 +66,7 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ - data = { - "url": f"{API_HOST}/submissions", - "message": "Successfully fetched the submissions", - "data": {} - } + data = {} try: with db.session() as session: submission = Submission() @@ -119,7 +112,17 @@ def post(self) -> dict[str, any]: session.add(submission) session.commit() - data["data"]["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["message"] = "Successfully fetched the submissions" + data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["data"] = { + "id": submission.submission_id, + "user": f"{API_HOST}/users/{submission.uid}", + "project": f"{API_HOST}/projects/{submission.project_id}", + "grading": submission.grading, + "time": submission.submission_time, + "path": submission.submission_path, + "status": submission.submission_status + } return data, 201 except exc.SQLAlchemyError: @@ -140,11 +143,7 @@ def get(self, submission_id: int) -> dict[str, any]: dict[str, any]: The submission """ - data = { - "url": f"{API_HOST}/submissions/{submission_id}", - "message": "Successfully fetched the submission", - "data": {} - } + data = {} try: with db.session() as session: submission = session.get(Submission, submission_id) @@ -152,7 +151,8 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 - data["data"]["submission"] = { + data["message"] = "Successfully fetched the submission" + data["data"] = { "id": submission.submission_id, "user": f"{API_HOST}/users/{submission.uid}", "project": f"{API_HOST}/projects/{submission.project_id}", @@ -178,11 +178,7 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ - data = { - "url": f"{API_HOST}/submissions/{submission_id}", - "message": f"Submission (submission_id={submission_id}) patched", - "data": {} - } + data = {} try: with db.session() as session: # Get the submission @@ -202,6 +198,17 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() + data["message"] = f"Submission (submission_id={submission_id}) patched" + data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["data"] = { + "id": submission.submission_id, + "user": f"{API_HOST}/users/{submission.uid}", + "project": f"{API_HOST}/projects/{submission.project_id}", + "grading": submission.grading, + "time": submission.submission_time, + "path": submission.submission_path, + "status": submission.submission_status + } return data, 200 except exc.SQLAlchemyError: @@ -220,11 +227,7 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = { - "url": f"{API_HOST}/submissions/{submission_id}", - "message": f"Submission (submission_id={submission_id}) deleted", - "data": {} - } + data = {} try: with db.session() as session: submission = session.get(Submission, submission_id) @@ -236,6 +239,7 @@ def delete(self, submission_id: int) -> dict[str, any]: session.delete(submission) session.commit() + data["message"] = f"Submission (submission_id={submission_id}) deleted" return data, 200 except exc.SQLAlchemyError: diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py index efba63f0..1e8987cd 100644 --- a/backend/project/models/submissions.py +++ b/backend/project/models/submissions.py @@ -1,8 +1,10 @@ """Model for submissions""" -from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean +from dataclasses import dataclass +from sqlalchemy import Column, String, ForeignKey, Integer, CheckConstraint, DateTime, Boolean from project.db_in import db +@dataclass class Submission(db.Model): """This class describes the submissions table, submissions can be made to a project, a submission has @@ -15,7 +17,7 @@ class Submission(db.Model): so we can easily present in a list which submission succeeded the automated checks""" __tablename__ = "submissions" - submission_id = Column(Integer, nullable=False, primary_key=True) + submission_id = Column(Integer, primary_key=True) uid = Column(String(255), ForeignKey("users.uid"), nullable=False) project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False) grading = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 8943136c..b515caf5 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -28,8 +28,8 @@ def users(): def courses(): """Return a list of courses to populate the database""" return [ - Course(name="AD3", teacher="brinkmann"), - Course(name="RAF", teacher="laermans"), + Course(course_id=1, name="AD3", teacher="brinkmann"), + Course(course_id=2, name="RAF", teacher="laermans"), ] @pytest.fixture @@ -54,6 +54,7 @@ def projects(courses): return [ Project( + project_id=1, title="B+ Trees", descriptions="Implement B+ trees", assignment_file="assignement.pdf", @@ -66,6 +67,7 @@ def projects(courses): regex_expressions=["*"] ), Project( + project_id=2, title="Predicaten", descriptions="Predicaten project", assignment_file="assignment.pdf", @@ -87,6 +89,7 @@ def submissions(projects): return [ Submission( + submission_id=1, uid="student01", project_id=project_id_ad3, grading=16, @@ -95,6 +98,7 @@ def submissions(projects): submission_status=True ), Submission( + submission_id=2, uid="student02", project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59), @@ -102,6 +106,7 @@ def submissions(projects): submission_status=False ), Submission( + submission_id=3, uid="student02", project_id=project_id_raf, grading=15, diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 7048f7d0..b64a1008 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -16,36 +16,29 @@ def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session) response = client.get("/submissions?uid=unknown") data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" - assert data["data"] == {} def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" - assert data["data"] == {} def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project of the wrong type""" response = client.get("/submissions?project_id=zero") data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" - assert data["data"] == {} def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2", f"{API_HOST}/submissions/3" @@ -56,9 +49,8 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): response = client.get("/submissions?uid=student01") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1" ] @@ -67,9 +59,8 @@ def test_get_submissions_project(self, client: FlaskClient, session: Session): response = client.get("/submissions?project_id=1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2" ] @@ -79,9 +70,8 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio response = client.get("/submissions?uid=student01&project_id=1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1" ] @@ -93,9 +83,7 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The uid data field is required" - assert data["data"] == {} def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing user""" @@ -105,9 +93,7 @@ def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" - assert data["data"] == {} def test_post_submissions_no_project(self, client: FlaskClient, session: Session): """Test posting a submission without specifying a project""" @@ -116,9 +102,7 @@ def test_post_submissions_no_project(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The project_id data field is required" - assert data["data"] == {} def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project""" @@ -128,9 +112,7 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" - assert data["data"] == {} def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project of the wrong type""" @@ -140,9 +122,7 @@ def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" - assert data["data"] == {} def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" @@ -153,9 +133,7 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading type""" @@ -166,9 +144,7 @@ def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" @@ -179,13 +155,11 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 201 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - - submission_id = int(data["data"]["submission"].split("/")[-1]) - submission = session.get(Submission, submission_id) - assert submission.uid == "student01" and submission.project_id == 1 \ - and submission.grading == 16 + assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" + assert data["data"]["user"] == f"{API_HOST}/users/student01" + assert data["data"]["project"] == f"{API_HOST}/projects/1" + assert data["data"]["grading"] == 16 ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -193,18 +167,15 @@ def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.get("/submissions/100") data = response.json assert response.status_code == 404 - assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" - assert data["data"] == {} def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" response = client.get("/submissions/1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions/1" assert data["message"] == "Successfully fetched the submission" - assert data["data"]["submission"] == { + assert data["data"] == { "id": 1, "user": f"{API_HOST}/users/student01", "project": f"{API_HOST}/projects/1", @@ -220,39 +191,38 @@ def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.patch("/submissions/100", data={"grading": 20}) data = response.json assert response.status_code == 404 - assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" - assert data["data"] == {} def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" response = client.patch("/submissions/2", data={"grading": 100}) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading type""" response = client.patch("/submissions/2", data={"grading": "zero"}) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" response = client.patch("/submissions/2", data={"grading": 20}) data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Submission (submission_id=2) patched" - assert data["data"] == {} - - submission = session.get(Submission, 2) - assert submission.grading == 20 + assert data["url"] == f"{API_HOST}/submissions/2" + assert data["data"] == { + "id": 2, + "user": f"{API_HOST}/users/student02", + "project": f"{API_HOST}/projects/1", + "grading": 20, + "time": 'Thu, 14 Mar 2024 22:59:59 GMT', + "path": "/submissions/2", + "status": False + } ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -260,18 +230,14 @@ def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session) response = client.delete("submissions/100") data = response.json assert response.status_code == 404 - assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" - assert data["data"] == {} def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" response = client.delete("submissions/1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions/1" assert data["message"] == "Submission (submission_id=1) deleted" - assert data["data"] == {} submission = session.get(Submission, 1) assert submission is None From f4440af8d85fa0767af8e7acc1acde8a29c18292 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 7 Mar 2024 18:22:33 +0100 Subject: [PATCH 051/144] #15 - Using urljoin instead of formatted string --- backend/project/endpoints/submissions.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index b31ca529..b5eff6c7 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -1,5 +1,6 @@ """Submission API endpoint""" +from urllib.parse import urljoin from datetime import datetime from os import getenv from dotenv import load_dotenv @@ -51,7 +52,7 @@ def get(self) -> dict[str, any]: # Get the submissions data["message"] = "Successfully fetched the submissions" data["data"] = [ - f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() + urljoin(API_HOST, f"/submissions/{s.submission_id}") for s in query.all() ] return data, 200 @@ -113,11 +114,11 @@ def post(self) -> dict[str, any]: session.commit() data["message"] = "Successfully fetched the submissions" - data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": f"{API_HOST}/users/{submission.uid}", - "project": f"{API_HOST}/projects/{submission.project_id}", + "user": urljoin(API_HOST, f"/users/{submission.uid}"), + "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -154,8 +155,8 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { "id": submission.submission_id, - "user": f"{API_HOST}/users/{submission.uid}", - "project": f"{API_HOST}/projects/{submission.project_id}", + "user": urljoin(API_HOST, f"/users/{submission.uid}"), + "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -199,11 +200,11 @@ def patch(self, submission_id:int) -> dict[str, any]: session.commit() data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": f"{API_HOST}/users/{submission.uid}", - "project": f"{API_HOST}/projects/{submission.project_id}", + "user": urljoin(API_HOST, f"/users/{submission.uid}"), + "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, From 9d8342415cdc375986ae2b6cb2f92c89ac822615 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 8 Mar 2024 15:50:53 +0100 Subject: [PATCH 052/144] 15 - Removed grading from post --- backend/project/endpoints/submissions.py | 8 ------- backend/tests/endpoints/submissions_test.py | 26 +-------------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index b5eff6c7..0f403d02 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -92,14 +92,6 @@ def post(self) -> dict[str, any]: return data, 400 submission.project_id = int(project_id) - # Grading - grading = request.form.get("grading") - if grading is not None: - if not (grading.isdigit() and 0 <= int(grading) <= 20): - data["message"] = "Invalid grading (grading=0-20)" - return data, 400 - submission.grading = int(grading) - # Submission time submission.submission_time = datetime.now() diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index b64a1008..5b057d3c 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -124,34 +124,11 @@ def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=zero)" - def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): - """Test posting a submission with a wrong grading""" - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": 1, - "grading": 80 - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" - - def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): - """Test posting a submission with a wrong grading type""" - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": 1, - "grading": "zero" - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" - def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, - "grading": 16 + "project_id": 1 }) data = response.json assert response.status_code == 201 @@ -159,7 +136,6 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" assert data["data"]["user"] == f"{API_HOST}/users/student01" assert data["data"]["project"] == f"{API_HOST}/projects/1" - assert data["data"]["grading"] == 16 ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): From cd5201809cc2cd2c90a548fa15dd1b18d6e90a63 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:10:51 +0100 Subject: [PATCH 053/144] #15 - Updating method descriptions --- .../endpoints/index/OpenAPI_Object.json | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3b61672a..50c85f4f 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1343,7 +1343,7 @@ }, "/submissions": { "get": { - "summary": "Get the submissions", + "summary": "Gets the submissions", "parameters": [ { "name": "uid", @@ -1364,7 +1364,7 @@ ], "responses": { "200": { - "description": "A list of submission URLs", + "description": "Successfully retrieved a list of submission URLs", "content": { "application/json": { "schema": { @@ -1389,7 +1389,7 @@ } }, "400": { - "description": "An 'invalid data' message", + "description": "An invalid user or project is given", "content": { "application/json": { "schema": { @@ -1404,7 +1404,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1421,7 +1421,7 @@ } }, "post": { - "summary": "Post a new submission to a project", + "summary": "Posts a new submission to a project", "requestBody": { "description": "Form data", "content": { @@ -1449,7 +1449,7 @@ }, "responses": { "201": { - "description": "The newly created submission URL", + "description": "Successfully posts the submission and retrieves its data", "content": { "application/json": { "schema": { @@ -1497,7 +1497,7 @@ } }, "400": { - "description": "An 'invalid data' message", + "description": "An invalid user or project is given", "content": { "application/json": { "schema": { @@ -1512,7 +1512,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1531,10 +1531,10 @@ }, "/submissions/{submission_id}": { "get": { - "summary": "Get the submission", + "summary": "Gets the submission", "responses": { "200": { - "description": "The submission", + "description": "Successfully retrieved the submission", "content": { "application/json": { "schema": { @@ -1584,7 +1584,7 @@ } }, "404": { - "description": "A 'not found' message", + "description": "An invalid submission id is given", "content": { "application/json": { "schema": { @@ -1599,7 +1599,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1616,7 +1616,7 @@ } }, "patch": { - "summary": "Patch the submission", + "summary": "Patches the submission", "requestBody": { "description": "The submission data", "content": { @@ -1636,7 +1636,7 @@ }, "responses": { "200": { - "description": "A 'submission updated' message", + "description": "Successfully patches the submission and retrieves its data", "content": { "application/json": { "schema": { @@ -1684,7 +1684,7 @@ } }, "400": { - "description": "An 'invalid data' message", + "description": "An invalid submission grading is given", "content": { "application/json": { "schema": { @@ -1699,7 +1699,7 @@ } }, "404": { - "description": "A 'not found' message", + "description": "An invalid submission id is given", "content": { "application/json": { "schema": { @@ -1714,7 +1714,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1731,10 +1731,10 @@ } }, "delete": { - "summary": "Delete the submission", + "summary": "Deletes the submission", "responses": { "200": { - "description": "A 'submission deleted' message", + "description": "Successfully deletes the submission", "content": { "application/json": { "schema": { @@ -1749,7 +1749,7 @@ } }, "404": { - "description": "A 'not found' message", + "description": "An invalid submission id is given", "content": { "application/json": { "schema": { @@ -1764,7 +1764,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { From 08d997741e066db41e1b43567af06cfa55294d5a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 19:23:44 +0100 Subject: [PATCH 054/144] #15 - Uploading files --- backend/project/__init__.py | 1 + backend/project/endpoints/submissions.py | 32 +++++++- backend/project/utils.py | 48 ++++++++++++ backend/tests/endpoints/conftest.py | 34 ++++++++- backend/tests/endpoints/submissions_test.py | 83 +++++++++++++++++---- 5 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 backend/project/utils.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index f81d3cfa..bcbb7630 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -36,6 +36,7 @@ def create_app_with_db(db_uri: str): app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri + app.config["UPLOAD_FOLDER"] = "/" db.init_app(app) return app diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 0f403d02..695f653b 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,7 +2,7 @@ from urllib.parse import urljoin from datetime import datetime -from os import getenv +from os import getenv, path from dotenv import load_dotenv from flask import Blueprint, request from flask_restful import Resource @@ -11,6 +11,7 @@ from project.models.submissions import Submission from project.models.projects import Project from project.models.users import User +from project.utils import check_filename, zip_files load_dotenv() API_HOST = getenv("API_HOST") @@ -96,8 +97,33 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - # Get the files, store them, test them ... - submission.submission_path = "/tbd" + regexes = session.get(Project, int(project_id)).regex_expressions + # Filter out incorrect or empty files + files = list(filter(lambda file: + file and file.filename != "" and path.getsize(file.filename) > 0, + request.files.getlist("files") + )) + + # Filter out files that don't follow the project's regexes + correct_files = list(filter(lambda file: + check_filename(file.filename, regexes), + files + )) + # Return with a bad request and tell which files where invalid + if not correct_files: + incorrect_files = [file.filename for file in files if file not in correct_files] + data["message"] = "No files were uploaded" if not files else \ + f"Invalid filename(s) (filenames={','.join(incorrect_files)})" + data["data"] = incorrect_files + return data, 400 + # Zip the files and save the zip + zip_file = zip_files("", correct_files) + if zip_file is None: + data["message"] = "Something went wrong while zipping the files" + return data, 500 + # FIXME app.config["UPLOAD_FOLDER"] instead of "/" + submission.submission_path = "/zip.zip" + zip_file.save(path.join("/", submission.submission_path)) # Submission status submission.submission_status = False diff --git a/backend/project/utils.py b/backend/project/utils.py new file mode 100644 index 00000000..0a83798d --- /dev/null +++ b/backend/project/utils.py @@ -0,0 +1,48 @@ +"""Utility functions""" + +from re import match +from typing import List +from io import BytesIO +from zipfile import ZipFile, ZIP_DEFLATED +from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage + +def check_filename(filename: str, regexes: List[str]) -> bool: + """Check if the filename + + Args: + filename (str): The filename + regex_list (List[str]): A list of regexes to match against + + Returns: + bool: Is the filename ok + """ + + # Return true if the filename matches for all regexes + return all(map(lambda regex: match(regex, filename) is not None, regexes)) + +def zip_files(name: str, files: List[FileStorage]) -> FileStorage | None: + """Zip a dictionary of files + + Args: + files (List[FileStorage]): The files to be zipped + + Returns: + FileStorage: The zipped file + """ + + compression = ZIP_DEFLATED # Compression algorithm + zip64 = False # Extension for larger files and archives (now limited to 4GB) + level = None # Compression level, None = default + + try: + buffer = BytesIO() + with ZipFile(buffer, "w", compression, zip64, level) as zip_file: + for file in files: + filename = secure_filename(file.filename) + zip_file.writestr(filename, file.stream.read()) + zip_file = FileStorage(buffer, name) + + return zip_file + except IOError: + return None diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index b515caf5..ba214da0 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,5 +1,6 @@ """ Configuration for pytest, Flask, and the test client.""" +import tempfile import os from datetime import datetime import pytest @@ -64,7 +65,7 @@ def projects(courses): archieved=False, test_path="/tests", script_name="script.sh", - regex_expressions=["*"] + regex_expressions=["solution"] ), Project( project_id=2, @@ -77,7 +78,7 @@ def projects(courses): archieved=True, test_path="/tests", script_name="script.sh", - regex_expressions=["*"] + regex_expressions=[".*"] ) ] @@ -116,6 +117,35 @@ def submissions(projects): ) ] +@pytest.fixture +def file_empty(): + """Return an empty file""" + descriptor, name = tempfile.mkstemp() + with open(descriptor, "rb") as temp: + yield temp, name + +@pytest.fixture +def file_no_name(): + """Return a file with no name""" + descriptor, name = tempfile.mkstemp() + with open(descriptor, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + with open(name, "rb") as temp: + yield temp, "" + +@pytest.fixture +def files(): + """Return a temporary file""" + descriptor01, name01 = tempfile.mkstemp() + with open(descriptor01, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + descriptor02, name02 = tempfile.mkstemp() + with open(descriptor02, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + with open(name01, "rb") as temp01: + with open(name02, "rb") as temp02: + yield [(temp01, name01), (temp02, name02)] + engine = create_engine(url) Session = sessionmaker(bind=engine) @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5b057d3c..56159de4 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -76,66 +76,123 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio ] ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, session: Session): + def test_post_submissions_no_user(self, client: FlaskClient, session: Session, files): """Test posting a submission without specifying a user""" response = client.post("/submissions", data={ - "project_id": 1 + "project_id": 1, + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The uid data field is required" - def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): + def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing user""" response = client.post("/submissions", data={ "uid": "unknown", - "project_id": 1 + "project_id": 1, + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid user (uid=unknown)" - def test_post_submissions_no_project(self, client: FlaskClient, session: Session): + def test_post_submissions_no_project(self, client: FlaskClient, session: Session, files): """Test posting a submission without specifying a project""" response = client.post("/submissions", data={ - "uid": "student01" + "uid": "student01", + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The project_id data field is required" - def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): + def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing project""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": -1 + "project_id": -1, + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" - def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + def test_post_submissions_wrong_project_type( + self, client: FlaskClient, session: Session, files + ): """Test posting a submission for a non-existing project of the wrong type""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": "zero" + "project_id": "zero", + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=zero)" - def test_post_submissions_correct(self, client: FlaskClient, session: Session): - """Test posting a submission""" + def test_post_submissions_no_files(self, client: FlaskClient, session: Session): + """Test posting a submission when no files are uploaded""" response = client.post("/submissions", data={ "uid": "student01", "project_id": 1 }) data = response.json + assert response.status_code == 400 + assert data["message"] == "No files were uploaded" + + def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): + """Test posting a submission for an empty file""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "files": file_empty + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "No files were uploaded" + + def test_post_submissions_file_with_no_name( + self, client: FlaskClient, session: Session, file_no_name + ): + """Test posting a submission for a file without a name""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "files": file_no_name + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "No files were uploaded" + + def test_post_submissions_file_with_wrong_name( + self, client: FlaskClient, session: Session, files + ): + """Test posting a submissions for a file with a wrong name""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "files": files + }) + data = response.json + assert response.status_code == 400 + assert "Invalid filename(s)" in data["message"] + + def test_post_submissions_correct( + self, client: FlaskClient, session: Session, files + ): + """Test posting a submission""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 2, + "files": files + }) + data = response.json assert response.status_code == 201 assert data["message"] == "Successfully fetched the submissions" assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" assert data["data"]["user"] == f"{API_HOST}/users/student01" - assert data["data"]["project"] == f"{API_HOST}/projects/1" + assert data["data"]["project"] == f"{API_HOST}/projects/2" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): From c5281a4a3a7121c61545e4ff4a45e471612a19f0 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 19:32:22 +0100 Subject: [PATCH 055/144] #15 - Forgot to update the API --- backend/project/endpoints/index/OpenAPI_Object.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 50c85f4f..f13fb4a1 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1437,10 +1437,11 @@ "type": "integer", "required": true }, - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 + "files": { + "type": "array", + "items": { + "type": "file" + } } } } @@ -1497,7 +1498,7 @@ } }, "400": { - "description": "An invalid user or project is given", + "description": "An invalid user, project or list of files is given", "content": { "application/json": { "schema": { From 145820db60f170cd255073baf9ff3d95bb1a2447 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 20:09:33 +0100 Subject: [PATCH 056/144] #15 - Moving utils.py to utils/files.py and fixing tests --- backend/project/endpoints/submissions.py | 8 ++++---- backend/project/{utils.py => utils/files.py} | 2 +- backend/tests/endpoints/submissions_test.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename backend/project/{utils.py => utils/files.py} (97%) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 695f653b..d39e66ad 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -8,10 +8,10 @@ from flask_restful import Resource from sqlalchemy import exc from project.db_in import db -from project.models.submissions import Submission -from project.models.projects import Project -from project.models.users import User -from project.utils import check_filename, zip_files +from project.models.submission import Submission +from project.models.project import Project +from project.models.user import User +from project.utils.files import check_filename, zip_files load_dotenv() API_HOST = getenv("API_HOST") diff --git a/backend/project/utils.py b/backend/project/utils/files.py similarity index 97% rename from backend/project/utils.py rename to backend/project/utils/files.py index 0a83798d..37a3f03f 100644 --- a/backend/project/utils.py +++ b/backend/project/utils/files.py @@ -1,4 +1,4 @@ -"""Utility functions""" +"""Utility functions for files""" from re import match from typing import List diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 56159de4..9af4d60e 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -3,7 +3,7 @@ from os import getenv from flask.testing import FlaskClient from sqlalchemy.orm import Session -from project.models.submissions import Submission +from project.models.submission import Submission API_HOST = getenv("API_HOST") From f441fcce7d24c8f862f9da2a7ddc65869c3574da Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 20:33:47 +0100 Subject: [PATCH 057/144] #15 - Fixing TypeError in GH tests --- backend/project/utils/files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 37a3f03f..9afdfee5 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -1,7 +1,7 @@ """Utility functions for files""" from re import match -from typing import List +from typing import List, Union from io import BytesIO from zipfile import ZipFile, ZIP_DEFLATED from werkzeug.utils import secure_filename @@ -21,7 +21,7 @@ def check_filename(filename: str, regexes: List[str]) -> bool: # Return true if the filename matches for all regexes return all(map(lambda regex: match(regex, filename) is not None, regexes)) -def zip_files(name: str, files: List[FileStorage]) -> FileStorage | None: +def zip_files(name: str, files: List[FileStorage]) -> Union[FileStorage, None]: """Zip a dictionary of files Args: From f5a80a6786f7677b00124370fa91634c8c722ad3 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 09:55:53 +0100 Subject: [PATCH 058/144] #15 - Trying to fix GH tests --- backend/tests/endpoints/conftest.py | 44 +++----- backend/tests/endpoints/submissions_test.py | 115 ++++++++++++-------- 2 files changed, 84 insertions(+), 75 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 47dbcf72..c556b9bf 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -14,7 +14,6 @@ from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission -@pytest.fixture def users(): """Return a list of users to populate the database""" return [ @@ -24,19 +23,17 @@ def users(): User(uid="student02", is_admin=False, is_teacher=False) ] -@pytest.fixture def courses(): """Return a list of courses to populate the database""" return [ - Course(course_id=1, name="AD3", teacher="brinkmann"), - Course(course_id=2, name="RAF", teacher="laermans"), + Course(name="AD3", teacher="brinkmann"), + Course(name="RAF", teacher="laermans"), ] -@pytest.fixture -def course_relations(courses): +def course_relations(session): """Returns a list of course relations to populate the database""" - course_id_ad3 = courses[0].course_id - course_id_raf = courses[1].course_id + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id return [ CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), @@ -46,15 +43,13 @@ def course_relations(courses): CourseStudent(course_id=course_id_raf, uid="student02") ] -@pytest.fixture -def projects(courses): +def projects(session): """Return a list of projects to populate the database""" - course_id_ad3 = courses[0].course_id - course_id_raf = courses[1].course_id + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id return [ Project( - project_id=1, title="B+ Trees", descriptions="Implement B+ trees", assignment_file="assignement.pdf", @@ -67,7 +62,6 @@ def projects(courses): regex_expressions=["solution"] ), Project( - project_id=2, title="Predicaten", descriptions="Predicaten project", assignment_file="assignment.pdf", @@ -81,15 +75,13 @@ def projects(courses): ) ] -@pytest.fixture -def submissions(projects): +def submissions(session): """Return a list of submissions to populate the database""" - project_id_ad3 = projects[0].project_id - project_id_raf = projects[1].project_id + project_id_ad3 = session.query(Project).filter_by(title="B+ Trees").first().project_id + project_id_raf = session.query(Project).filter_by(title="Predicaten").first().project_id return [ Submission( - submission_id=1, uid="student01", project_id=project_id_ad3, grading=16, @@ -98,7 +90,6 @@ def submissions(projects): submission_status=True ), Submission( - submission_id=2, uid="student02", project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59), @@ -106,7 +97,6 @@ def submissions(projects): submission_status=False ), Submission( - submission_id=3, uid="student02", project_id=project_id_raf, grading=15, @@ -148,7 +138,7 @@ def files(): engine = create_engine(url) Session = sessionmaker(bind=engine) @pytest.fixture -def session(users,courses,course_relations,projects,submissions): +def session(): """Create a database session for the tests""" # Create all tables and get a session db.metadata.create_all(engine) @@ -156,15 +146,15 @@ def session(users,courses,course_relations,projects,submissions): try: # Populate the database - session.add_all(users) + session.add_all(users()) session.commit() - session.add_all(courses) + session.add_all(courses()) session.commit() - session.add_all(course_relations) + session.add_all(course_relations(session)) session.commit() - session.add_all(projects) + session.add_all(projects(session)) session.commit() - session.add_all(submissions) + session.add_all(submissions(session)) session.commit() # Tests can now use a populated database diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 9af4d60e..d2ee3c0b 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -3,6 +3,7 @@ from os import getenv from flask.testing import FlaskClient from sqlalchemy.orm import Session +from project.models.project import Project from project.models.submission import Submission API_HOST = getenv("API_HOST") @@ -38,11 +39,7 @@ def test_get_submissions_all(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1", - f"{API_HOST}/submissions/2", - f"{API_HOST}/submissions/3" - ] + assert len(data["data"]) == 3 def test_get_submissions_user(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user""" @@ -50,36 +47,32 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1" - ] + assert len(data["data"]) == 1 def test_get_submissions_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific project""" - response = client.get("/submissions?project_id=1") + project = session.query(Project).filter_by(title="B+ Trees").first() + response = client.get(f"/submissions?project_id={project.project_id}") data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1", - f"{API_HOST}/submissions/2" - ] + assert len(data["data"]) == 2 def test_get_submissions_user_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user and project""" - response = client.get("/submissions?uid=student01&project_id=1") + project = session.query(Project).filter_by(title="B+ Trees").first() + response = client.get(f"/submissions?uid=student01&project_id={project.project_id}") data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1" - ] + assert len(data["data"]) == 1 ### POST SUBMISSIONS ### def test_post_submissions_no_user(self, client: FlaskClient, session: Session, files): """Test posting a submission without specifying a user""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ - "project_id": 1, + "project_id": project.project_id, "files": files }) data = response.json @@ -88,9 +81,10 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session, f def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing user""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "unknown", - "project_id": 1, + "project_id": project.project_id, "files": files }) data = response.json @@ -133,9 +127,10 @@ def test_post_submissions_wrong_project_type( def test_post_submissions_no_files(self, client: FlaskClient, session: Session): """Test posting a submission when no files are uploaded""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1 + "project_id": project.project_id }) data = response.json assert response.status_code == 400 @@ -143,9 +138,10 @@ def test_post_submissions_no_files(self, client: FlaskClient, session: Session): def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): """Test posting a submission for an empty file""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, + "project_id": project.project_id, "files": file_empty }) data = response.json @@ -156,9 +152,10 @@ def test_post_submissions_file_with_no_name( self, client: FlaskClient, session: Session, file_no_name ): """Test posting a submission for a file without a name""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, + "project_id": project.project_id, "files": file_no_name }) data = response.json @@ -169,9 +166,10 @@ def test_post_submissions_file_with_wrong_name( self, client: FlaskClient, session: Session, files ): """Test posting a submissions for a file with a wrong name""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, + "project_id": project.project_id, "files": files }) data = response.json @@ -182,36 +180,41 @@ def test_post_submissions_correct( self, client: FlaskClient, session: Session, files ): """Test posting a submission""" + project = session.query(Project).filter_by(title="Predicaten").first() response = client.post("/submissions", data={ - "uid": "student01", - "project_id": 2, + "uid": "student02", + "project_id": project.project_id, "files": files }) data = response.json assert response.status_code == 201 assert data["message"] == "Successfully fetched the submissions" assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" - assert data["data"]["user"] == f"{API_HOST}/users/student01" - assert data["data"]["project"] == f"{API_HOST}/projects/2" + assert data["data"]["user"] == f"{API_HOST}/users/student02" + assert data["data"]["project"] == f"{API_HOST}/projects/{project.project_id}" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - response = client.get("/submissions/100") + response = client.get("/submissions/0") data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=100) not found" + assert data["message"] == "Submission (submission_id=0) not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" - response = client.get("/submissions/1") + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student01", project_id=project.project_id + ).first() + response = client.get(f"/submissions/{submission.submission_id}") data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" assert data["data"] == { - "id": 1, + "id": submission.submission_id, "user": f"{API_HOST}/users/student01", - "project": f"{API_HOST}/projects/1", + "project": f"{API_HOST}/projects/{project.project_id}", "grading": 16, "time": "Thu, 14 Mar 2024 11:00:00 GMT", "path": "/submissions/1", @@ -221,36 +224,48 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/100", data={"grading": 20}) + response = client.patch("/submissions/0", data={"grading": 20}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=100) not found" + assert data["message"] == "Submission (submission_id=0) not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" - response = client.patch("/submissions/2", data={"grading": 100}) + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student02", project_id=project.project_id + ).first() + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading type""" - response = client.patch("/submissions/2", data={"grading": "zero"}) + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student02", project_id=project.project_id + ).first() + response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" - response = client.patch("/submissions/2", data={"grading": 20}) + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student02", project_id=project.project_id + ).first() + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}) data = response.json assert response.status_code == 200 - assert data["message"] == "Submission (submission_id=2) patched" - assert data["url"] == f"{API_HOST}/submissions/2" + assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" + assert data["url"] == f"{API_HOST}/submissions/{submission.submission_id}" assert data["data"] == { - "id": 2, + "id": submission.submission_id, "user": f"{API_HOST}/users/student02", - "project": f"{API_HOST}/projects/1", + "project": f"{API_HOST}/projects/{project.project_id}", "grading": 20, "time": 'Thu, 14 Mar 2024 22:59:59 GMT', "path": "/submissions/2", @@ -260,17 +275,21 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - response = client.delete("submissions/100") + response = client.delete("submissions/0") data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=100) not found" + assert data["message"] == "Submission (submission_id=0) not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" - response = client.delete("submissions/1") + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student01", project_id=project.project_id + ).first() + response = client.delete(f"submissions/{submission.submission_id}") data = response.json assert response.status_code == 200 - assert data["message"] == "Submission (submission_id=1) deleted" - - submission = session.get(Submission, 1) - assert submission is None + assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" + assert submission.submission_id not in list(map( + lambda s: s.submission_id, session.query(Submission).all() + )) From 1c45ebb91b7e58b89d24343bb8fb111f1a5bcbd8 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 10:19:17 +0100 Subject: [PATCH 059/144] #15 - Fixing datetime issues in tests --- backend/tests/endpoints/conftest.py | 7 ++++--- backend/tests/endpoints/submissions_test.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index c556b9bf..1b8f1a1e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,6 +3,7 @@ import tempfile import os from datetime import datetime +from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -85,14 +86,14 @@ def submissions(session): uid="student01", project_id=project_id_ad3, grading=16, - submission_time=datetime(2024,3,14,12,0,0), + submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/1", submission_status=True ), Submission( uid="student02", project_id=project_id_ad3, - submission_time=datetime(2024,3,14,23,59,59), + submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/2", submission_status=False ), @@ -100,7 +101,7 @@ def submissions(session): uid="student02", project_id=project_id_raf, grading=15, - submission_time=datetime(2023,3,5,10,0,0), + submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/3", submission_status=True ) diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index d2ee3c0b..d12c3d7d 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -216,7 +216,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): "user": f"{API_HOST}/users/student01", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 16, - "time": "Thu, 14 Mar 2024 11:00:00 GMT", + "time": "Thu, 14 Mar 2024 12:00:00 GMT", "path": "/submissions/1", "status": True } @@ -267,7 +267,7 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): "user": f"{API_HOST}/users/student02", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 20, - "time": 'Thu, 14 Mar 2024 22:59:59 GMT', + "time": 'Thu, 14 Mar 2024 23:59:59 GMT', "path": "/submissions/2", "status": False } From c388b14c131012a0015066afacb9298a70b8ae1a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 10:27:21 +0100 Subject: [PATCH 060/144] #15 - Updating urljoin --- backend/project/endpoints/submissions.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index d39e66ad..8b7f002f 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -53,7 +53,7 @@ def get(self) -> dict[str, any]: # Get the submissions data["message"] = "Successfully fetched the submissions" data["data"] = [ - urljoin(API_HOST, f"/submissions/{s.submission_id}") for s in query.all() + urljoin(f"{API_HOST}/", f"/submissions/{s.submission_id}") for s in query.all() ] return data, 200 @@ -132,11 +132,11 @@ def post(self) -> dict[str, any]: session.commit() data["message"] = "Successfully fetched the submissions" - data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") + data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": urljoin(API_HOST, f"/users/{submission.uid}"), - "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -173,8 +173,8 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { "id": submission.submission_id, - "user": urljoin(API_HOST, f"/users/{submission.uid}"), - "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -218,11 +218,11 @@ def patch(self, submission_id:int) -> dict[str, any]: session.commit() data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") + data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": urljoin(API_HOST, f"/users/{submission.uid}"), - "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, From 8975fae3af8cf9e194703d8c88aea9d05673385a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:07:00 +0100 Subject: [PATCH 061/144] #15 - Fixing missing url field --- .../endpoints/index/OpenAPI_Object.json | 68 +++++++++++++++++-- backend/project/endpoints/submissions.py | 21 ++++-- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3d3fd2f6..3b70d27d 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1570,6 +1570,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, @@ -1595,6 +1599,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1610,6 +1618,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1656,13 +1668,13 @@ "schema": { "type": "object", "properties": { - "message": { - "type": "string" - }, "url": { "type": "string", "format": "uri" }, + "message": { + "type": "string" + }, "data": { "type": "object", "properties": { @@ -1704,6 +1716,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1719,6 +1735,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1741,6 +1761,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, @@ -1791,6 +1815,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1806,6 +1834,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1843,13 +1875,13 @@ "schema": { "type": "object", "properties": { - "message": { - "type": "string" - }, "url": { "type": "string", "format": "uri" }, + "message": { + "type": "string" + }, "data": { "type": "object", "properties": { @@ -1891,6 +1923,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1906,6 +1942,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1921,6 +1961,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1941,6 +1985,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1956,6 +2004,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1971,6 +2023,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 8b7f002f..9dc49442 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -28,7 +28,9 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", "/submissions") + } try: with db.session() as session: query = session.query(Submission) @@ -68,7 +70,9 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", "/submissions") + } try: with db.session() as session: submission = Submission() @@ -114,7 +118,6 @@ def post(self) -> dict[str, any]: incorrect_files = [file.filename for file in files if file not in correct_files] data["message"] = "No files were uploaded" if not files else \ f"Invalid filename(s) (filenames={','.join(incorrect_files)})" - data["data"] = incorrect_files return data, 400 # Zip the files and save the zip zip_file = zip_files("", correct_files) @@ -162,7 +165,9 @@ def get(self, submission_id: int) -> dict[str, any]: dict[str, any]: The submission """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + } try: with db.session() as session: submission = session.get(Submission, submission_id) @@ -197,7 +202,9 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + } try: with db.session() as session: # Get the submission @@ -246,7 +253,9 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", "/submissions") + } try: with db.session() as session: submission = session.get(Submission, submission_id) From 9cf0014ca08b94d0fc40959e6eee92c3d3b53d43 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:13:20 +0100 Subject: [PATCH 062/144] #15 - Fixing multiple querying, still linter issues, but deal with that later --- backend/project/endpoints/submissions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 9dc49442..ac2c0394 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -92,7 +92,11 @@ def post(self) -> dict[str, any]: if project_id is None: data["message"] = "The project_id data field is required" return data, 400 - if not project_id.isdigit() or session.get(Project, int(project_id)) is None: + if not project_id.isdigit(): + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 + project = session.get(Project, int(project_id)) + if project is None: data["message"] = f"Invalid project (project_id={project_id})" return data, 400 submission.project_id = int(project_id) @@ -101,7 +105,7 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - regexes = session.get(Project, int(project_id)).regex_expressions + regexes = project.regex_expressions # Filter out incorrect or empty files files = list(filter(lambda file: file and file.filename != "" and path.getsize(file.filename) > 0, From 3da63b4d7378f50552407cf929fda99afa8c4da7 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:33:26 +0100 Subject: [PATCH 063/144] #15 - Updating checking required files --- backend/project/endpoints/submissions.py | 22 +++++++++------------ backend/project/utils/files.py | 19 ++++++++++++++++++ backend/tests/endpoints/submissions_test.py | 4 ++-- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index ac2c0394..46d1fb10 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -11,7 +11,7 @@ from project.models.submission import Submission from project.models.project import Project from project.models.user import User -from project.utils.files import check_filename, zip_files +from project.utils.files import all_files_uploaded, zip_files load_dotenv() API_HOST = getenv("API_HOST") @@ -105,26 +105,22 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - regexes = project.regex_expressions # Filter out incorrect or empty files files = list(filter(lambda file: file and file.filename != "" and path.getsize(file.filename) > 0, request.files.getlist("files") )) + if not files: + data["message"] = "No files were uploaded" + return data, 400 - # Filter out files that don't follow the project's regexes - correct_files = list(filter(lambda file: - check_filename(file.filename, regexes), - files - )) - # Return with a bad request and tell which files where invalid - if not correct_files: - incorrect_files = [file.filename for file in files if file not in correct_files] - data["message"] = "No files were uploaded" if not files else \ - f"Invalid filename(s) (filenames={','.join(incorrect_files)})" + # Check if all files are uploaded + if not all_files_uploaded(files, project.regex_expressions): + data["message"] = "Not all required files were uploaded" return data, 400 + # Zip the files and save the zip - zip_file = zip_files("", correct_files) + zip_file = zip_files("", files) if zip_file is None: data["message"] = "Something went wrong while zipping the files" return data, 500 diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 9afdfee5..2b440287 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -21,6 +21,25 @@ def check_filename(filename: str, regexes: List[str]) -> bool: # Return true if the filename matches for all regexes return all(map(lambda regex: match(regex, filename) is not None, regexes)) +def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: + """Check if all the required files are uploaded + + Args: + files (List[FileStorage]): The files uploaded + regexes (List[str]): The list of regexes to match against + + Returns: + bool: Are all required files uploaded + """ + + all_uploaded = True + for regex in regexes: + match_found = any(match(regex, file.filename) is not None for file in files) + if not match_found: + all_uploaded = False + break + return all_uploaded + def zip_files(name: str, files: List[FileStorage]) -> Union[FileStorage, None]: """Zip a dictionary of files diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index d12c3d7d..c99ac82d 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -162,7 +162,7 @@ def test_post_submissions_file_with_no_name( assert response.status_code == 400 assert data["message"] == "No files were uploaded" - def test_post_submissions_file_with_wrong_name( + def test_post_submissions_missing_required_files( self, client: FlaskClient, session: Session, files ): """Test posting a submissions for a file with a wrong name""" @@ -174,7 +174,7 @@ def test_post_submissions_file_with_wrong_name( }) data = response.json assert response.status_code == 400 - assert "Invalid filename(s)" in data["message"] + assert data["message"] == "Not all required files were uploaded" def test_post_submissions_correct( self, client: FlaskClient, session: Session, files From ee2e668bf90dea3acb43f8e7d88c8d89149f6491 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:34:19 +0100 Subject: [PATCH 064/144] #15 - Removing unused function --- backend/project/utils/files.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 2b440287..d38ad3db 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -7,20 +7,6 @@ from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage -def check_filename(filename: str, regexes: List[str]) -> bool: - """Check if the filename - - Args: - filename (str): The filename - regex_list (List[str]): A list of regexes to match against - - Returns: - bool: Is the filename ok - """ - - # Return true if the filename matches for all regexes - return all(map(lambda regex: match(regex, filename) is not None, regexes)) - def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: """Check if all the required files are uploaded From 0b0a32c948f5c3917816006d7fd1d30729c3e1fd Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:36:21 +0100 Subject: [PATCH 065/144] #15 - Using environment variable UPLOAD_FOLDER --- backend/project/endpoints/submissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 46d1fb10..4ce8b34b 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -15,6 +15,7 @@ load_dotenv() API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") submissions_bp = Blueprint("submissions", __name__) @@ -124,9 +125,8 @@ def post(self) -> dict[str, any]: if zip_file is None: data["message"] = "Something went wrong while zipping the files" return data, 500 - # FIXME app.config["UPLOAD_FOLDER"] instead of "/" submission.submission_path = "/zip.zip" - zip_file.save(path.join("/", submission.submission_path)) + zip_file.save(path.join(f"{UPLOAD_FOLDER}/", submission.submission_path)) # Submission status submission.submission_status = False From f40fc46b63f78594c374f4874806f78e98eb8cb6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:39:21 +0100 Subject: [PATCH 066/144] #15 - Add static typing to the submissions model --- backend/project/models/submission.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index 1e8987cd..e2309eea 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -17,10 +17,10 @@ class Submission(db.Model): so we can easily present in a list which submission succeeded the automated checks""" __tablename__ = "submissions" - submission_id = Column(Integer, primary_key=True) - uid = Column(String(255), ForeignKey("users.uid"), nullable=False) - project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False) - grading = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) - submission_time = Column(DateTime(timezone=True), nullable=False) - submission_path = Column(String(50), nullable=False) - submission_status = Column(Boolean, nullable=False) + submission_id: int = Column(Integer, primary_key=True) + uid: str = Column(String(255), ForeignKey("users.uid"), nullable=False) + project_id: int = Column(Integer, ForeignKey("projects.project_id"), nullable=False) + grading: int = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) + submission_time: DateTime = Column(DateTime(timezone=True), nullable=False) + submission_path: str = Column(String(50), nullable=False) + submission_status: bool = Column(Boolean, nullable=False) From 93032d0c2a4f8eda7a88a1ffff0d5b3346f36690 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 13:14:53 +0100 Subject: [PATCH 067/144] #15 - Fixing linter --- backend/project/endpoints/submissions.py | 41 +++++---------- backend/project/utils/files.py | 15 ++++++ backend/project/utils/project.py | 27 ++++++++++ backend/project/utils/user.py | 26 +++++++++ backend/tests/conftest.py | 20 ++++--- backend/tests/endpoints/conftest.py | 58 +++++---------------- backend/tests/endpoints/submissions_test.py | 10 ++-- 7 files changed, 114 insertions(+), 83 deletions(-) create mode 100644 backend/project/utils/project.py create mode 100644 backend/project/utils/user.py diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 4ce8b34b..b7045501 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -11,7 +11,9 @@ from project.models.submission import Submission from project.models.project import Project from project.models.user import User -from project.utils.files import all_files_uploaded, zip_files +from project.utils.files import filter_files, all_files_uploaded, zip_files +from project.utils.user import is_valid_user +from project.utils.project import is_valid_project load_dotenv() API_HOST = getenv("API_HOST") @@ -80,25 +82,17 @@ def post(self) -> dict[str, any]: # User uid = request.form.get("uid") - if (uid is None) or (session.get(User, uid) is None): - if uid is None: - data["message"] = "The uid data field is required" - else: - data["message"] = f"Invalid user (uid={uid})" + valid, message = is_valid_user(session, uid) + if not valid: + data["message"] = message return data, 400 submission.uid = uid # Project project_id = request.form.get("project_id") - if project_id is None: - data["message"] = "The project_id data field is required" - return data, 400 - if not project_id.isdigit(): - data["message"] = f"Invalid project (project_id={project_id})" - return data, 400 - project = session.get(Project, int(project_id)) - if project is None: - data["message"] = f"Invalid project (project_id={project_id})" + valid, message = is_valid_project(session, project_id) + if not valid: + data["message"] = message return data, 400 submission.project_id = int(project_id) @@ -106,18 +100,11 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - # Filter out incorrect or empty files - files = list(filter(lambda file: - file and file.filename != "" and path.getsize(file.filename) > 0, - request.files.getlist("files") - )) - if not files: - data["message"] = "No files were uploaded" - return data, 400 - - # Check if all files are uploaded - if not all_files_uploaded(files, project.regex_expressions): - data["message"] = "Not all required files were uploaded" + files = filter_files(request.files.getlist("files")) + project = session.get(Project, submission.project_id) + if not files or not all_files_uploaded(files, project.regex_expressions): + data["message"] = "No files were uploaded" if not files else \ + "Not all required files were uploaded" return data, 400 # Zip the files and save the zip diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index d38ad3db..b577e218 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -1,5 +1,6 @@ """Utility functions for files""" +from os.path import getsize from re import match from typing import List, Union from io import BytesIO @@ -7,6 +8,20 @@ from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage +def filter_files(files: List[FileStorage]) -> List[FileStorage]: + """Filter out bad files + + Args: + files (List[FileStorage]): A list of files to filter on + + Returns: + List[FileStorage]: The filtered list + """ + return list(filter(lambda file: + file and file.filename != "" and getsize(file.filename) > 0, + files + )) + def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: """Check if all the required files are uploaded diff --git a/backend/project/utils/project.py b/backend/project/utils/project.py new file mode 100644 index 00000000..f056fa2b --- /dev/null +++ b/backend/project/utils/project.py @@ -0,0 +1,27 @@ +"""Utility functions for the project model""" + +from typing import Tuple +from sqlalchemy.orm import Session +from project.models.project import Project + +def is_valid_project(session: Session, project_id: any) -> Tuple[bool, str]: + """Check if a project_id is valid + + Args: + project_id (any): The project_id + + Returns: + bool: Is valid + """ + if project_id is None: + return False, "The project_id is missing" + + if isinstance(project_id, str) and project_id.isdigit(): + project_id = int(project_id) + elif not isinstance(project_id, int): + return False, f"Invalid project_id typing (project_id={project_id})" + + project = session.get(Project, project_id) + if project is None: + return False, f"Invalid project (project_id={project_id})" + return True, "Valid project" diff --git a/backend/project/utils/user.py b/backend/project/utils/user.py new file mode 100644 index 00000000..6b491066 --- /dev/null +++ b/backend/project/utils/user.py @@ -0,0 +1,26 @@ +"""Utility functions for the user model""" + +from typing import Tuple +from sqlalchemy.orm import Session +from project.models.user import User + +def is_valid_user(session: Session, uid: any) -> Tuple[bool, str]: + """Check if a uid is valid + + Args: + session (Session): A database session + uid (any): The uid + + Returns: + Tuple[bool, str]: Is valid + """ + if uid is None: + return False, "The uid is missing" + + if not isinstance(uid, str): + return False, f"Invalid uid typing (uid={uid})" + + user = session.get(User, uid) + if user is None: + return False, f"Invalid user (uid={uid})" + return True, "Valid user" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 13f1a853..0ff5b009 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,12 +7,18 @@ def db_session(): """Create a new database session for a test. After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) session = Session() - yield session - session.rollback() - session.close() - # Truncate all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() + + try: + yield session + finally: + # Rollback + session.rollback() + session.close() + + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 1b8f1a1e..de15708a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,7 +6,6 @@ from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project.db_in import url, db from project.models.course import Course @@ -136,39 +135,23 @@ def files(): with open(name02, "rb") as temp02: yield [(temp01, name01), (temp02, name02)] -engine = create_engine(url) -Session = sessionmaker(bind=engine) @pytest.fixture -def session(): +def session(db_session): """Create a database session for the tests""" - # Create all tables and get a session - db.metadata.create_all(engine) - session = Session() - - try: - # Populate the database - session.add_all(users()) - session.commit() - session.add_all(courses()) - session.commit() - session.add_all(course_relations(session)) - session.commit() - session.add_all(projects(session)) - session.commit() - session.add_all(submissions(session)) - session.commit() - - # Tests can now use a populated database - yield session - finally: - # Rollback - session.rollback() - session.close() + # Populate the database + db_session.add_all(users()) + db_session.commit() + db_session.add_all(courses()) + db_session.commit() + db_session.add_all(course_relations(db_session)) + db_session.commit() + db_session.add_all(projects(db_session)) + db_session.commit() + db_session.add_all(submissions(db_session)) + db_session.commit() - # Remove all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() + # Tests can now use a populated database + yield db_session @pytest.fixture def app(): @@ -240,19 +223,6 @@ def client(app): with app.app_context(): yield client -@pytest.fixture -def db_session(app): - """Create a new database session for a test. - After the test, all changes are rolled back and the session is closed.""" - app = create_app_with_db(url) - with app.app_context(): - for table in reversed(db.metadata.sorted_tables): - db.session.execute(table.delete()) - db.session.commit() - - yield db.session - db.session.close() - @pytest.fixture def courses_get_db(db_with_course): """Database equipped for the get tests""" diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index c99ac82d..7736bf90 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -77,7 +77,7 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session, f }) data = response.json assert response.status_code == 400 - assert data["message"] == "The uid data field is required" + assert data["message"] == "The uid is missing" def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing user""" @@ -99,18 +99,18 @@ def test_post_submissions_no_project(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 - assert data["message"] == "The project_id data field is required" + assert data["message"] == "The project_id is missing" def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing project""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": -1, + "project_id": 0, "files": files }) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=-1)" + assert data["message"] == "Invalid project (project_id=0)" def test_post_submissions_wrong_project_type( self, client: FlaskClient, session: Session, files @@ -123,7 +123,7 @@ def test_post_submissions_wrong_project_type( }) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=zero)" + assert data["message"] == "Invalid project_id typing (project_id=zero)" def test_post_submissions_no_files(self, client: FlaskClient, session: Session): """Test posting a submission when no files are uploaded""" From f257342e9ba13d6b1e353dbab676fbfc47eb5f7d Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 10 Mar 2024 16:32:31 +0100 Subject: [PATCH 068/144] docker-compose added --- backend/Dockerfile | 3 ++- backend/project/__main__.py | 2 +- docker-compose.yml | 19 +++++++++++++++++++ frontend/Dockerfile | 4 ++-- 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 docker-compose.yml diff --git a/backend/Dockerfile b/backend/Dockerfile index 8e4fa18e..b99144d6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,5 +2,6 @@ FROM python:3.9 RUN mkdir /app WORKDIR /app/ ADD ./project /app/ +COPY . . RUN pip3 install -r requirements.txt -CMD ["python3", "/app"] \ No newline at end of file +CMD ["python3", "-m","project"] \ No newline at end of file diff --git a/backend/project/__main__.py b/backend/project/__main__.py index a4bd122b..444d1410 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -7,4 +7,4 @@ if __name__ == "__main__": load_dotenv() app = create_app_with_db(url) - app.run(debug=True) + app.run(debug=True, host='0.0.0.0') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..c55b1736 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "8080:80" + depends_on: + - backend + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "3000:3000" + diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 043eb166..44c3f196 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,6 @@ FROM node:18 as build WORKDIR /app -COPY package*.json . +COPY package*.json ./ RUN npm install COPY . . RUN npm run build @@ -9,4 +9,4 @@ FROM nginx:alpine WORKDIR /usr/share/nginx/html RUN rm -rf ./* COPY --from=build /app/dist /usr/share/nginx/html -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] From 7f9d22e45265ed508f2f0a2ec2e9c94eb168288d Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 16:50:17 +0100 Subject: [PATCH 069/144] #15 - Will fix this later --- backend/tests/endpoints/courses_test.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 96b80459..5e9fde7d 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -188,20 +188,15 @@ def test_course_delete(self, courses_get_db, client): course = Course.query.filter_by(name="Sel2").first() assert course.teacher == "Bart" - response = client.delete( - "/courses/" + str(course.course_id) + "?uid=" + course.teacher - ) - assert response.status_code == 200 - course = courses_get_db.query(Course).filter_by(name="Sel2").first() - assert course is None - - def test_course_patch(self, db_with_course, client): + def test_course_patch(self, client, session): """ Test the patching of a course """ - body = {"name": "AD2"} - course = db_with_course.query(Course).filter_by(name="Sel2").first() - response = client.patch(f"/courses/{course.course_id}?uid=Bart", json=body) + course = session.query(Course).filter_by(name="AD3").first() + response = client.patch(f"/courses/{course.course_id}?uid=brinkmann", json={ + "name": "AD2" + }) + data = response.json assert response.status_code == 200 - assert course.name == "AD2" + assert data["data"]["name"] == "AD2" From 064f9634139485bdaacebbf756663958058ca0f8 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 17:06:04 +0100 Subject: [PATCH 070/144] #15 - Linter has cyclic imports --- backend/project/endpoints/courses/course_details.py | 2 +- backend/project/endpoints/courses/course_student_relation.py | 2 +- backend/project/endpoints/courses/courses_utils.py | 2 +- backend/project/endpoints/users.py | 2 +- backend/project/models/course.py | 2 +- backend/project/models/course_relation.py | 2 +- backend/project/models/project.py | 2 +- backend/project/models/user.py | 2 +- backend/tests/endpoints/user_test.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index e4b4458f..41b4abd5 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -17,7 +17,7 @@ from project.models.course import Course from project.models.course_relation import CourseAdmin, CourseStudent -from project import db +from project.db_in import db from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model load_dotenv() diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index b86429f0..63b9213d 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -13,7 +13,7 @@ from flask import request from flask_restful import Resource -from project import db +from project.db_in import db from project.models.course_relation import CourseStudent from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index cc5fdc1e..da496e0d 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -10,7 +10,7 @@ from flask import abort from sqlalchemy.exc import SQLAlchemyError -from project import db +from project.db_in import db from project.models.course_relation import CourseAdmin from project.models.user import User from project.models.course import Course diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 2febaffd..cfaf63db 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -6,7 +6,7 @@ from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError -from project import db +from project.db_in import db from project.models.user import User as userModel users_bp = Blueprint("users", __name__) diff --git a/backend/project/models/course.py b/backend/project/models/course.py index 8d3f0651..09b37e5a 100644 --- a/backend/project/models/course.py +++ b/backend/project/models/course.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String -from project import db +from project.db_in import db @dataclass class Course(db.Model): diff --git a/backend/project/models/course_relation.py b/backend/project/models/course_relation.py index 9ee45c08..41b7d40b 100644 --- a/backend/project/models/course_relation.py +++ b/backend/project/models/course_relation.py @@ -1,6 +1,6 @@ """Models for relation between users and courses""" from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String -from project import db +from project.db_in import db class BaseCourseRelation(db.Model): """Base class for course relation models, diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 5171e1e6..06d76efc 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -2,7 +2,7 @@ import dataclasses from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text -from project import db +from project.db_in import db @dataclasses.dataclass class Project(db.Model): # pylint: disable=too-many-instance-attributes diff --git a/backend/project/models/user.py b/backend/project/models/user.py index d325a60c..7597462b 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -2,7 +2,7 @@ import dataclasses from sqlalchemy import Boolean, Column, String -from project import db +from project.db_in import db @dataclasses.dataclass class User(db.Model): diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 98ed1010..96b13e3c 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from project.models.user import User -from project import db +from project.db_in import db from tests import db_url engine = create_engine(db_url) From 1fc9900339704f60c769a87750be2746afc0933e Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 10 Mar 2024 17:57:19 +0100 Subject: [PATCH 071/144] change port --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index c55b1736..8f007744 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,5 @@ services: context: ./backend dockerfile: Dockerfile ports: - - "3000:3000" + - "5000:5000" From e7d3ba81663dcd4dddadab7e2fe4681edee829b7 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Mon, 11 Mar 2024 11:40:22 +0100 Subject: [PATCH 072/144] #15 - Small URL change --- backend/project/endpoints/submissions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index b7045501..07e3d4f9 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -159,6 +159,7 @@ def get(self, submission_id: int) -> dict[str, any]: with db.session() as session: submission = session.get(Submission, submission_id) if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 @@ -197,6 +198,7 @@ def patch(self, submission_id:int) -> dict[str, any]: # Get the submission submission = session.get(Submission, submission_id) if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 @@ -247,6 +249,7 @@ def delete(self, submission_id: int) -> dict[str, any]: with db.session() as session: submission = session.get(Submission, submission_id) if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 From 11377d16f83ce2e68d034de3530a9e281450032c Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 13:41:44 +0100 Subject: [PATCH 073/144] fixed extracting of zip and uploading in project upload directory --- .../project/endpoints/projects/projects.py | 32 +++++++++++--- backend/project/utils/query_agent.py | 44 ++++++++++++------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index af303cc9..03572a30 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -8,8 +8,10 @@ from flask import request from flask_restful import Resource +import zipfile + from project.models.projects import Project -from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.query_agent import query_selected_from_model, insert_into_model, create_model_instance from project.endpoints.projects.endpoint_parser import parse_project_params @@ -63,15 +65,31 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() - print("args") - print(arg) # save the file that is given with the request + + new_new_project = create_model_instance( + Project, + project_json, + urljoin(API_URL, "/projects"), + required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"] + ) + + print(new_new_project) + id = new_new_project.project_id + print(id) + project_upload_directory = f"{UPLOAD_FOLDER}{new_new_project.project_id}" + file_location = "."+os.path.join(project_upload_directory) + print(file_location) + # print(new_new_project.json) + if not os.path.exists(project_upload_directory): + os.makedirs(file_location) + if allowed_file(file.filename): - file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + file.save(file_location+"/"+file.filename) + with zipfile.ZipFile(file_location+"/"+file.filename) as zip: + zip.extractall(file_location) else: print("no zip file given") - new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects"), "project_id", required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"]) - - return new_project + return {}, 200 diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 24e857e2..5f258282 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -50,6 +50,28 @@ def delete_by_id_from_model( return {"error": "Something went wrong while deleting from the database.", "url": base_url}, 500 + +def create_model_instance(model: DeclarativeMeta, + data: Dict[str, Union[str, int]], + response_url_base: str, + required_fields: List[str] = None): + if required_fields is None: + required_fields = [] + # Check if all non-nullable fields are present in the data + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}", + "url": response_url_base}, 400 + + filtered_data = filter_model_fields(model, data) + new_instance: DeclarativeMeta = model(**filtered_data) + db.session.add(new_instance) + db.session.commit() + + return new_instance + + def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, @@ -69,26 +91,14 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - if required_fields is None: - required_fields = [] - # Check if all non-nullable fields are present in the data - missing_fields = [field for field in required_fields if field not in data] - - if missing_fields: - return {"error": f"Missing required fields: {', '.join(missing_fields)}", - "url": response_url_base}, 400 - - filtered_data = filter_model_fields(model, data) - new_instance: DeclarativeMeta = model(**filtered_data) - db.session.add(new_instance) - db.session.commit() + new_instance = create_model_instance(model, data, response_url_base, required_fields) + return jsonify({ "data": new_instance, "message": "Object created succesfully.", - "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError as e: - print("error") - print(e) + "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 + except SQLAlchemyError: + db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", "url": response_url_base}), 500 From bc6e4a9793e9176cb871e3a2d51353d02ac104a1 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 13:44:37 +0100 Subject: [PATCH 074/144] fix 400 when non zip is uploaded --- backend/project/endpoints/projects/projects.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 03572a30..e13f358e 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -90,6 +90,10 @@ def post(self): with zipfile.ZipFile(file_location+"/"+file.filename) as zip: zip.extractall(file_location) else: - print("no zip file given") + return {"message": "Please provide a .zip file for uploading the instructions"}, 400 - return {}, 200 + return { + "message": "Project created succesfully", + "data": new_new_project, + "url": f"{API_URL}/projects/{id}" + }, 200 From 62632be484243e8f04b14bbe8c7b2a962965b0d4 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 15:42:19 +0100 Subject: [PATCH 075/144] fixed tests --- .../project/endpoints/projects/projects.py | 28 ++++++++++--------- backend/project/utils/query_agent.py | 14 ++++++---- backend/tests.yaml | 1 + backend/tests/endpoints/conftest.py | 1 - backend/tests/endpoints/project_test.py | 15 ++++++---- 5 files changed, 35 insertions(+), 24 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index e13f358e..0da4861f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -19,6 +19,7 @@ UPLOAD_FOLDER = getenv('UPLOAD_URL') ALLOWED_EXTENSIONS = {'zip'} + def parse_immutabledict(request): output_json = {} for key, value in request.form.items(): @@ -31,9 +32,11 @@ def parse_immutabledict(request): output_json[key] = value return output_json + def allowed_file(filename: str): return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + class ProjectsEndpoint(Resource): """ @@ -65,6 +68,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() + filename = file.filename.split("/")[-1] # save the file that is given with the request @@ -74,26 +78,24 @@ def post(self): urljoin(API_URL, "/projects"), required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"] ) - - print(new_new_project) id = new_new_project.project_id - print(id) + project_upload_directory = f"{UPLOAD_FOLDER}{new_new_project.project_id}" - file_location = "."+os.path.join(project_upload_directory) - print(file_location) - # print(new_new_project.json) + + file_location = "." + os.path.join(project_upload_directory) + if not os.path.exists(project_upload_directory): - os.makedirs(file_location) + os.makedirs(file_location, exist_ok=True) - if allowed_file(file.filename): - file.save(file_location+"/"+file.filename) - with zipfile.ZipFile(file_location+"/"+file.filename) as zip: + file.save(file_location + "/" + filename) + try: + with zipfile.ZipFile(file_location + "/" + filename) as zip: zip.extractall(file_location) - else: + except zipfile.BadZipfile: return {"message": "Please provide a .zip file for uploading the instructions"}, 400 return { "message": "Project created succesfully", "data": new_new_project, "url": f"{API_URL}/projects/{id}" - }, 200 + }, 201 diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 5f258282..8eb7f4cc 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -92,11 +92,15 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance = create_model_instance(model, data, response_url_base, required_fields) - - return jsonify({ - "data": new_instance, - "message": "Object created succesfully.", - "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 + # if its a tuple the model instance couldn't be created so it already + # is the right format of error message and we just need to return + if isinstance(new_instance, tuple): + return new_instance + else: + return jsonify({ + "data": new_instance, + "message": "Object created succesfully.", + "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 except SQLAlchemyError: db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", diff --git a/backend/tests.yaml b/backend/tests.yaml index 7b799a2e..2807d904 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -29,6 +29,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here + UPLOAD_URL: /project/endpoints/uploads/ volumes: - .:/app command: ["pytest"] diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 0e964c22..143e463a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -33,7 +33,6 @@ def project(course): title="Project", descriptions="Test project", course_id=course.course_id, - assignment_file="testfile", deadline=date, visible_for_students=True, archieved=False, diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 1ebecce4..2ab36c26 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,5 +1,6 @@ """Tests for project endpoints.""" from project.models.projects import Project +import pytest def test_projects_home(client): """Test home project endpoint.""" @@ -23,9 +24,14 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ db_session.commit() project_json["course_id"] = course_ad.course_id + project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - response = client.post("/projects", json=project_json) + response = client.post( + "/projects", + data=project_json, + content_type='multipart/form-data' + ) assert response.status_code == 201 # check if the project with the id is present @@ -34,7 +40,6 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ assert response.status_code == 200 - def test_remove_project(db_session, client, course_ad, course_teacher_ad, project_json): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" @@ -45,10 +50,11 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec db_session.commit() project_json["course_id"] = course_ad.course_id + project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - response = client.post("/projects", json=project_json) - + response = client.post("/projects", data=project_json) + print(response) # check if the project with the id is present project_id = response.json["data"]["project_id"] @@ -59,7 +65,6 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec response = client.delete(f"/projects/{project_id}") assert response.status_code == 404 - def test_patch_project(db_session, client, course_ad, course_teacher_ad, project): """ Test functionality of the PUT method for projects From 6f323ec8f70e398a37e6fa411cc3edaed348e51e Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 15:44:03 +0100 Subject: [PATCH 076/144] added test zip file --- backend/testzip.zip | Bin 0 -> 175 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/testzip.zip diff --git a/backend/testzip.zip b/backend/testzip.zip new file mode 100644 index 0000000000000000000000000000000000000000..f99b6a05484f7a0a5ffa496e382505de4987825a GIT binary patch literal 175 zcmWIWW@h1H00H-^>Y!K#PkYOlEEiTb3sVE5z;bdT5CiFgaB@mZZa5FHn zykKTv023fJX_+~xTmjyUOmfV)43hxa!N3T_TN*(ugwd=JqtT2F@MdKLsbd5}KOpT5 H;xGUJBf=uQ literal 0 HcmV?d00001 From b5f8ef9288c6bb1ba11fa9b2f6b47382aecb2b52 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 16:03:45 +0100 Subject: [PATCH 077/144] linter fixes --- backend/project/__main__.py | 2 - .../endpoints/projects/endpoint_parser.py | 21 ++++++++-- .../endpoints/projects/project_endpoint.py | 1 - .../project/endpoints/projects/projects.py | 41 +++++++++---------- backend/project/utils/query_agent.py | 16 +++++--- backend/tests/endpoints/project_test.py | 7 +++- 6 files changed, 53 insertions(+), 35 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 32547c6e..a4bd122b 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,7 +1,5 @@ """Main entry point for the application.""" -from sys import path -path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index d5ece633..7815bf5e 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -8,14 +8,29 @@ parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title', location="form") parser.add_argument('descriptions', type=str, help='Projects description', location="form") -parser.add_argument('assignment_file', type=werkzeug.datastructures.FileStorage, help='Projects assignment file', location="form") +parser.add_argument( + 'assignment_file', + type=werkzeug.datastructures.FileStorage, + help='Projects assignment file', + location="form" +) parser.add_argument("deadline", type=str, help='Projects deadline', location="form") parser.add_argument("course_id", type=str, help='Projects course_id', location="form") -parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students', location="form") +parser.add_argument( + "visible_for_students", + type=bool, + help='Projects visibility for students', + location="form" +) parser.add_argument("archieved", type=bool, help='Projects', location="form") parser.add_argument("test_path", type=str, help='Projects test path', location="form") parser.add_argument("script_name", type=str, help='Projects test script path', location="form") -parser.add_argument("regex_expressions", type=str, help='Projects regex expressions', location="form") +parser.add_argument( + "regex_expressions", + type=str, + help='Projects regex expressions', + location="form" +) def parse_project_params(): diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index eef5b34d..09938878 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -4,7 +4,6 @@ """ from flask import Blueprint -from flask_restful import Api from project.endpoints.projects.projects import ProjectsEndpoint from project.endpoints.projects.project_detail import ProjectDetail diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 0da4861f..cbfddec0 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -5,13 +5,13 @@ from os import getenv from urllib.parse import urljoin +import zipfile + from flask import request from flask_restful import Resource -import zipfile - from project.models.projects import Project -from project.utils.query_agent import query_selected_from_model, insert_into_model, create_model_instance +from project.utils.query_agent import query_selected_from_model, create_model_instance from project.endpoints.projects.endpoint_parser import parse_project_params @@ -20,20 +20,13 @@ ALLOWED_EXTENSIONS = {'zip'} -def parse_immutabledict(request): - output_json = {} - for key, value in request.form.items(): - if value == "false": - print("false") - output_json[key] = False - if value == "true": - output_json[key] = True - else: - output_json[key] = value - return output_json + def allowed_file(filename: str): + """ + check if file extension is allowed for upload + """ return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @@ -72,15 +65,19 @@ def post(self): # save the file that is given with the request - new_new_project = create_model_instance( + new_project = create_model_instance( Project, project_json, urljoin(API_URL, "/projects"), - required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"] + required_fields=[ + "title", + "descriptions", + "course_id", + "visible_for_students", + "archieved"] ) - id = new_new_project.project_id - project_upload_directory = f"{UPLOAD_FOLDER}{new_new_project.project_id}" + project_upload_directory = f"{UPLOAD_FOLDER}{new_project.project_id}" file_location = "." + os.path.join(project_upload_directory) @@ -89,13 +86,13 @@ def post(self): file.save(file_location + "/" + filename) try: - with zipfile.ZipFile(file_location + "/" + filename) as zip: - zip.extractall(file_location) + with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: + upload_zip.extractall(file_location) except zipfile.BadZipfile: return {"message": "Please provide a .zip file for uploading the instructions"}, 400 return { "message": "Project created succesfully", - "data": new_new_project, - "url": f"{API_URL}/projects/{id}" + "data": new_project, + "url": f"{API_URL}/projects/{new_project.project_id}" }, 201 diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 8eb7f4cc..c1729a6d 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -55,6 +55,9 @@ def create_model_instance(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, required_fields: List[str] = None): + """ + Create an instance of a model + """ if required_fields is None: required_fields = [] # Check if all non-nullable fields are present in the data @@ -96,11 +99,14 @@ def insert_into_model(model: DeclarativeMeta, # is the right format of error message and we just need to return if isinstance(new_instance, tuple): return new_instance - else: - return jsonify({ - "data": new_instance, - "message": "Object created succesfully.", - "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 + + return (jsonify({ + "data": new_instance, + "message": "Object created succesfully.", + "url": + urljoin(response_url_base + "/", + str(getattr(new_instance, url_id_field)))}), + 201) except SQLAlchemyError: db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 2ab36c26..50d00d07 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,6 +1,5 @@ """Tests for project endpoints.""" from project.models.projects import Project -import pytest def test_projects_home(client): """Test home project endpoint.""" @@ -24,6 +23,8 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ db_session.commit() project_json["course_id"] = course_ad.course_id + # cant be done with 'with' because it autocloses then + # pylint: disable=R1732 project_json["assignment_file"] = open("testzip.zip", "rb") # post the project @@ -50,8 +51,10 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec db_session.commit() project_json["course_id"] = course_ad.course_id - project_json["assignment_file"] = open("testzip.zip", "rb") + # cant be done with 'with' because it autocloses then + # pylint: disable=R1732 + project_json["assignment_file"] = open("testzip.zip", "rb") # post the project response = client.post("/projects", data=project_json) print(response) From 72a9b7db31bc5397b2f8f79c4552a748f9bd0a3a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 16:08:11 +0100 Subject: [PATCH 078/144] removed some test files --- backend/project/endpoints/projects/endpoint_parser.py | 1 - backend/tests/endpoints/project_test.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 7815bf5e..2f5be9bb 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -39,7 +39,6 @@ def parse_project_params(): """ args = parser.parse_args() result_dict = {} - print(args) for key, value in args.items(): if value is not None: diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 50d00d07..54cedbf2 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -57,7 +57,7 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec project_json["assignment_file"] = open("testzip.zip", "rb") # post the project response = client.post("/projects", data=project_json) - print(response) + # check if the project with the id is present project_id = response.json["data"]["project_id"] From 12d0aa5f2d6dd9f84acb6fd26faed473357bec48 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:01:01 +0100 Subject: [PATCH 079/144] linter and test fixes --- backend/project/__init__.py | 1 - backend/project/endpoints/projects/projects.py | 5 ----- 2 files changed, 6 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index fa7891df..299412f1 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -7,7 +7,6 @@ from .endpoints.index.index import index_bp from .endpoints.projects.project_endpoint import project_bp from .endpoints.courses.courses_config import courses_bp -from .endpoints.courses.courses_config import courses_bp from .endpoints.users import users_bp diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index a1ba6cde..8d6ae75f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -4,17 +4,12 @@ import os from os import getenv from urllib.parse import urljoin - -from flask import request import zipfile from flask import request from flask_restful import Resource from project.models.project import Project -from project.utils.query_agent import query_selected_from_model, insert_into_model - -from project.models.projects import Project from project.utils.query_agent import query_selected_from_model, create_model_instance from project.endpoints.projects.endpoint_parser import parse_project_params From e1624b12bc31cc91595cdf2de67bffb272b1a272 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:50:49 +0100 Subject: [PATCH 080/144] import depedency fix --- backend/project/endpoints/projects/endpoint_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 2f5be9bb..3c64f9e5 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -3,14 +3,14 @@ """ from flask_restful import reqparse -import werkzeug +from werkzeug.datastructures import FileStorage parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title', location="form") parser.add_argument('descriptions', type=str, help='Projects description', location="form") parser.add_argument( 'assignment_file', - type=werkzeug.datastructures.FileStorage, + type=FileStorage, help='Projects assignment file', location="form" ) From 123992c4d3db058b812b788303ac6e1c87b5e38a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:53:57 +0100 Subject: [PATCH 081/144] fix import order --- backend/project/endpoints/projects/project_detail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index f8d4ebf1..85d7b99c 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -9,9 +9,9 @@ from flask import request from flask_restful import Resource +from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model -from project.models.project import Project From 2c3a71914fee62bc5c43f4de737ab7411c1009ae Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:54:31 +0100 Subject: [PATCH 082/144] removed import getenv --- backend/project/endpoints/projects/projects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 8d6ae75f..84412ddf 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,7 +2,6 @@ Module that implements the /projects endpoint of the API """ import os -from os import getenv from urllib.parse import urljoin import zipfile From 33880d6c960bf78a0c89021eab07b3a529c31225 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:59:49 +0100 Subject: [PATCH 083/144] removed valid_project function --- backend/project/endpoints/projects/projects.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 84412ddf..00f8c9aa 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -13,21 +13,8 @@ from project.endpoints.projects.endpoint_parser import parse_project_params -API_URL = getenv('API_HOST') -UPLOAD_FOLDER = getenv('UPLOAD_URL') -ALLOWED_EXTENSIONS = {'zip'} - - - - - -def allowed_file(filename: str): - """ - check if file extension is allowed for upload - """ - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - +API_URL = os.getenv('API_HOST') +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') class ProjectsEndpoint(Resource): """ From 10b0c1aa560aac5f3bf9a849fd643bf910c2fe2a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:14:50 +0100 Subject: [PATCH 084/144] fix fstring --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 00f8c9aa..261d5861 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -53,7 +53,7 @@ def post(self): new_project = create_model_instance( Project, project_json, - urljoin(API_URL, "/projects"), + urljoin(f"{API_URL}/", "/projects"), required_fields=[ "title", "descriptions", From 0edbd46c83872a656582405c68f9ab41e3db0a0c Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:19:30 +0100 Subject: [PATCH 085/144] fix: upload_directory --- backend/project/endpoints/projects/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 261d5861..ca1bf08a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -62,9 +62,9 @@ def post(self): "archieved"] ) - project_upload_directory = f"{UPLOAD_FOLDER}{new_project.project_id}" + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") - file_location = "." + os.path.join(project_upload_directory) + file_location = os.path.join(project_upload_directory) if not os.path.exists(project_upload_directory): os.makedirs(file_location, exist_ok=True) From 3438972b1233a7ae14351ee6928528cc7d743312 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:21:56 +0100 Subject: [PATCH 086/144] removed exist_ok=True --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ca1bf08a..a2767c89 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -67,7 +67,7 @@ def post(self): file_location = os.path.join(project_upload_directory) if not os.path.exists(project_upload_directory): - os.makedirs(file_location, exist_ok=True) + os.makedirs(file_location) file.save(file_location + "/" + filename) try: From 8892d9873874f4bab4fc01f5bd47a33a46cc8816 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:28:16 +0100 Subject: [PATCH 087/144] use path.join --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index a2767c89..8602f39c 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -69,7 +69,7 @@ def post(self): if not os.path.exists(project_upload_directory): os.makedirs(file_location) - file.save(file_location + "/" + filename) + file.save(os.path.join(file_location, filename)) try: with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: upload_zip.extractall(file_location) From c5e3bc44b94e771ae5ae00f619b598bd08516f32 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:29:25 +0100 Subject: [PATCH 088/144] added url field --- backend/project/endpoints/projects/projects.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 8602f39c..5fde6a53 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -74,7 +74,11 @@ def post(self): with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: upload_zip.extractall(file_location) except zipfile.BadZipfile: - return {"message": "Please provide a .zip file for uploading the instructions"}, 400 + return ({ + "message": "Please provide a .zip file for uploading the instructions", + "url": f"{API_URL}/projects" + }, + 400) return { "message": "Project created succesfully", From 5c6a28deb9bd82bde9cf813babef027fd2bdc6ca Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:44:08 +0100 Subject: [PATCH 089/144] fixed not checking for tuple type anymore --- backend/project/endpoints/projects/projects.py | 2 +- backend/project/utils/query_agent.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 5fde6a53..d6091a2e 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -60,7 +60,7 @@ def post(self): "course_id", "visible_for_students", "archieved"] - ) + )[0] project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 41cb9e86..2398d9be 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -72,7 +72,7 @@ def create_model_instance(model: DeclarativeMeta, db.session.add(new_instance) db.session.commit() - return new_instance + return new_instance, 201 def insert_into_model(model: DeclarativeMeta, @@ -95,18 +95,20 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance = create_model_instance(model, data, response_url_base, required_fields) + model_instance = new_instance[0] + status_code = new_instance[1] # if its a tuple the model instance couldn't be created so it already # is the right format of error message and we just need to return - if isinstance(new_instance, tuple): - return new_instance + if status_code == 400: + return model_instance, status_code return (jsonify({ - "data": new_instance, + "data": model_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base + "/", - str(getattr(new_instance, url_id_field)))}), - 201) + str(getattr(model_instance, url_id_field)))}), + status_code) except SQLAlchemyError: db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", From 0be828f0aaf1286e732abc50d9113aa8653d3103 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:45:42 +0100 Subject: [PATCH 090/144] fixed env var for tests --- backend/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests.yaml b/backend/tests.yaml index 2807d904..fd6d7a16 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -29,7 +29,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - UPLOAD_URL: /project/endpoints/uploads/ + UPLOAD_URL: /data/assignments volumes: - .:/app command: ["pytest"] From adbb14baa4f0762fae7bfdb5f61bef192c3aabff Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:50:18 +0100 Subject: [PATCH 091/144] fixed env var for tests --- backend/tests/endpoints/project_test.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 369dc97b..8e2c49a9 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -54,9 +54,12 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec # cant be done with 'with' because it autocloses then # pylint: disable=R1732 - project_json["assignment_file"] = open("testzip.zip", "rb") + # project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - response = client.post("/projects", data=project_json) + # response = client.post("/projects", data=project_json) + with open("testzip.zip", "rb") as zip_file: + project_json["assignment_file"] = open("testzip.zip", "rb") + response = client.post("/projects", data=project_json) # check if the project with the id is present project_id = response.json["data"]["project_id"] From f0be9495970651d44cd5e42c479e1560427f69d9 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:53:41 +0100 Subject: [PATCH 092/144] fixed with statements --- backend/tests/endpoints/project_test.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 8e2c49a9..b833a03d 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -25,14 +25,15 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ project_json["course_id"] = course_ad.course_id # cant be done with 'with' because it autocloses then # pylint: disable=R1732 - project_json["assignment_file"] = open("testzip.zip", "rb") + with open("testzip.zip", "rb") as zip_file: + project_json["assignment_file"] = zip_file + # post the project + response = client.post( + "/projects", + data=project_json, + content_type='multipart/form-data' + ) - # post the project - response = client.post( - "/projects", - data=project_json, - content_type='multipart/form-data' - ) assert response.status_code == 201 # check if the project with the id is present @@ -52,13 +53,9 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec project_json["course_id"] = course_ad.course_id - # cant be done with 'with' because it autocloses then - # pylint: disable=R1732 - # project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - # response = client.post("/projects", data=project_json) with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = open("testzip.zip", "rb") + project_json["assignment_file"] = zip_file response = client.post("/projects", data=project_json) # check if the project with the id is present From 220856fa81f1a5a6499bb4d8f19e645f7dec0886 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 20:02:34 +0100 Subject: [PATCH 093/144] using os.path.split instead of regular split --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index d6091a2e..2a0a2d9a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -46,7 +46,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() - filename = file.filename.split("/")[-1] + filename = os.path.split(file.filename)[1] # save the file that is given with the request From 8c848d68f429f271c0299e1ab1cb9f675ce4fc5a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:20:01 +0100 Subject: [PATCH 094/144] added exist_ok --- backend/project/endpoints/projects/projects.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 2a0a2d9a..f7dec135 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -66,8 +66,7 @@ def post(self): file_location = os.path.join(project_upload_directory) - if not os.path.exists(project_upload_directory): - os.makedirs(file_location) + os.makedirs(file_location, exist_ok=True) file.save(os.path.join(file_location, filename)) try: From cb7eac1c8a8cc44938883980b717cec920686e13 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:25:43 +0100 Subject: [PATCH 095/144] i forgot :skull: fix lmao yeet --- backend/project/endpoints/projects/projects.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index f7dec135..624949ca 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -64,14 +64,12 @@ def post(self): project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") - file_location = os.path.join(project_upload_directory) + os.makedirs(project_upload_directory, exist_ok=True) - os.makedirs(file_location, exist_ok=True) - - file.save(os.path.join(file_location, filename)) + file.save(os.path.join(project_upload_directory, filename)) try: - with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: - upload_zip.extractall(file_location) + with zipfile.ZipFile(project_upload_directory + "/" + filename) as upload_zip: + upload_zip.extractall(project_upload_directory) except zipfile.BadZipfile: return ({ "message": "Please provide a .zip file for uploading the instructions", From 6db6fada770ec71e37b30314a25da9a33046883f Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:27:14 +0100 Subject: [PATCH 096/144] i forgot :skull: fix lmao yeet --- backend/project/utils/query_agent.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 2398d9be..d530f8a6 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -94,9 +94,8 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - new_instance = create_model_instance(model, data, response_url_base, required_fields) - model_instance = new_instance[0] - status_code = new_instance[1] + model_instance, status_code = create_model_instance(model, data, response_url_base, required_fields) + # if its a tuple the model instance couldn't be created so it already # is the right format of error message and we just need to return if status_code == 400: From 058f53e5f925f4cfa3873223bcf4a0f787662e06 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:27:57 +0100 Subject: [PATCH 097/144] goofy augh fstring --- backend/project/utils/query_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index d530f8a6..ab6e8973 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -105,7 +105,7 @@ def insert_into_model(model: DeclarativeMeta, "data": model_instance, "message": "Object created succesfully.", "url": - urljoin(response_url_base + "/", + urljoin(f"{response_url_base}/", str(getattr(model_instance, url_id_field)))}), status_code) except SQLAlchemyError: From 2314ff6652a66fd65777637baa826ee42c096c4f Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:29:22 +0100 Subject: [PATCH 098/144] another small fix --- backend/project/endpoints/projects/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 624949ca..6369b4fc 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -50,7 +50,7 @@ def post(self): # save the file that is given with the request - new_project = create_model_instance( + new_project, _ = create_model_instance( Project, project_json, urljoin(f"{API_URL}/", "/projects"), @@ -60,7 +60,7 @@ def post(self): "course_id", "visible_for_students", "archieved"] - )[0] + ) project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") From 251ff2905d7d5b81a1dea60a2f6c3b6d3df79971 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:31:37 +0100 Subject: [PATCH 099/144] fixed the 'not fixed eh' problem --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 6369b4fc..39815429 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -68,7 +68,7 @@ def post(self): file.save(os.path.join(project_upload_directory, filename)) try: - with zipfile.ZipFile(project_upload_directory + "/" + filename) as upload_zip: + with zipfile.ZipFile(os.path.join(project_upload_directory, filename)) as upload_zip: upload_zip.extractall(project_upload_directory) except zipfile.BadZipfile: return ({ From f01fed999aa07437503bb54755891245f11d3f63 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:34:35 +0100 Subject: [PATCH 100/144] linting --- backend/project/utils/query_agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index ab6e8973..bcdf1ea0 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -94,7 +94,11 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - model_instance, status_code = create_model_instance(model, data, response_url_base, required_fields) + model_instance, status_code = create_model_instance( + model, + data, + response_url_base, + required_fields) # if its a tuple the model instance couldn't be created so it already # is the right format of error message and we just need to return From d53eef1061720364d33e7a0c381a41a7b43a7408 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:39:34 +0100 Subject: [PATCH 101/144] fix handling fail --- backend/project/endpoints/projects/projects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 39815429..bfa65a0e 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -50,7 +50,7 @@ def post(self): # save the file that is given with the request - new_project, _ = create_model_instance( + new_project, status_code = create_model_instance( Project, project_json, urljoin(f"{API_URL}/", "/projects"), @@ -62,6 +62,9 @@ def post(self): "archieved"] ) + if status_code == 400: + return new_project, status_code + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") os.makedirs(project_upload_directory, exist_ok=True) From dad70154761b641e52f1f0fc52cdc5b7b881eaf4 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 12 Mar 2024 12:42:57 +0100 Subject: [PATCH 102/144] added try block --- .../project/endpoints/projects/projects.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index bfa65a0e..b86bd021 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -49,18 +49,22 @@ def post(self): filename = os.path.split(file.filename)[1] # save the file that is given with the request - - new_project, status_code = create_model_instance( - Project, - project_json, - urljoin(f"{API_URL}/", "/projects"), - required_fields=[ - "title", - "descriptions", - "course_id", - "visible_for_students", - "archieved"] - ) + try: + new_project, status_code = create_model_instance( + Project, + project_json, + urljoin(f"{API_URL}/", "/projects"), + required_fields=[ + "title", + "descriptions", + "course_id", + "visible_for_students", + "archieved"] + ) + except SQLAlchemyError: + db.session.rollback() + return jsonify({"error": "Something went wrong while inserting into the database.", + "url": f"{API_URL}/projects"}), 500 if status_code == 400: return new_project, status_code From f4fe9fa83979bfcb0c60c14bd38fa4cd18de63a5 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 12 Mar 2024 13:09:57 +0100 Subject: [PATCH 103/144] linter --- backend/project/endpoints/projects/projects.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index b86bd021..ed26f568 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -4,10 +4,12 @@ import os from urllib.parse import urljoin import zipfile +from sqlalchemy.exc import SQLAlchemyError -from flask import request +from flask import request, jsonify from flask_restful import Resource + from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance @@ -62,7 +64,6 @@ def post(self): "archieved"] ) except SQLAlchemyError: - db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", "url": f"{API_URL}/projects"}), 500 From 998e69e60b88766c469f0dbf3a477c6fa410e5df Mon Sep 17 00:00:00 2001 From: warre Date: Tue, 12 Mar 2024 15:17:21 +0100 Subject: [PATCH 104/144] nginx.conf added --- nginx.conf | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 nginx.conf diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..f47deca4 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,103 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + server { + server_name sel2-3.ugent.be localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + proxy_pass http://localhost:8080; + } + + + location /api/{ + proxy_pass http://localhost:5000/; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/sel2-3.ugent.be/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/sel2-3.ugent.be/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + +} + + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; + + + + server { + if ($host = sel2-3.ugent.be) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + listen 80; + listen [::]:80; + server_name sel2-3.ugent.be localhost; + return 404; # managed by Certbot + + +}} + + + From 5dc72f05b68907a0c3da4db94021ff564aa1d9dd Mon Sep 17 00:00:00 2001 From: warre Date: Tue, 12 Mar 2024 17:14:19 +0100 Subject: [PATCH 105/144] rm conf --- nginx.conf | 103 ----------------------------------------------------- 1 file changed, 103 deletions(-) delete mode 100644 nginx.conf diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index f47deca4..00000000 --- a/nginx.conf +++ /dev/null @@ -1,103 +0,0 @@ -user www-data; -worker_processes auto; -pid /run/nginx.pid; -include /etc/nginx/modules-enabled/*.conf; - -events { - worker_connections 768; - # multi_accept on; -} - -http { - server { - server_name sel2-3.ugent.be localhost; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - proxy_pass http://localhost:8080; - } - - - location /api/{ - proxy_pass http://localhost:5000/; - } - - listen [::]:443 ssl ipv6only=on; # managed by Certbot - listen 443 ssl; # managed by Certbot - ssl_certificate /etc/letsencrypt/live/sel2-3.ugent.be/fullchain.pem; # managed by Certbot - ssl_certificate_key /etc/letsencrypt/live/sel2-3.ugent.be/privkey.pem; # managed by Certbot - include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot - -} - - - ## - # Basic Settings - ## - - sendfile on; - tcp_nopush on; - types_hash_max_size 2048; - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - ## - # SSL Settings - ## - - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE - ssl_prefer_server_ciphers on; - - ## - # Logging Settings - ## - - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - - ## - # Gzip Settings - ## - - gzip on; - - # gzip_vary on; - # gzip_proxied any; - # gzip_comp_level 6; - # gzip_buffers 16 8k; - # gzip_http_version 1.1; - # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - ## - # Virtual Host Configs - ## - - include /etc/nginx/conf.d/*.conf; - include /etc/nginx/sites-enabled/*; - - - - server { - if ($host = sel2-3.ugent.be) { - return 301 https://$host$request_uri; - } # managed by Certbot - - - listen 80; - listen [::]:80; - server_name sel2-3.ugent.be localhost; - return 404; # managed by Certbot - - -}} - - - From c5c8c90aa61a7d8f760a766a5b3e1702fcf8871c Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:29:57 +0100 Subject: [PATCH 106/144] fixed archieved to archived typo (#84) * fixed archieved to archived typo * fixed typo descriptions to description * last change * tests passed! --- backend/db_construct.sql | 4 ++-- backend/project/endpoints/projects/endpoint_parser.py | 4 ++-- backend/project/endpoints/projects/projects.py | 6 +++--- backend/project/models/project.py | 4 ++-- backend/tests/endpoints/conftest.py | 8 ++++---- backend/tests/endpoints/project_test.py | 9 ++++++--- backend/tests/models/conftest.py | 4 ++-- 7 files changed, 21 insertions(+), 18 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index d0884c13..8c42e382 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -30,12 +30,12 @@ CREATE TABLE course_students ( CREATE TABLE projects ( project_id INT GENERATED ALWAYS AS IDENTITY, title VARCHAR(50) NOT NULL, - descriptions TEXT NOT NULL, + description TEXT NOT NULL, assignment_file VARCHAR(50), deadline TIMESTAMP WITH TIME ZONE, course_id INT NOT NULL, visible_for_students BOOLEAN NOT NULL, - archieved BOOLEAN NOT NULL, + archived BOOLEAN NOT NULL, test_path VARCHAR(50), script_name VARCHAR(50), regex_expressions VARCHAR(50)[], diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 3c64f9e5..d9737826 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -7,7 +7,7 @@ parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title', location="form") -parser.add_argument('descriptions', type=str, help='Projects description', location="form") +parser.add_argument('description', type=str, help='Projects description', location="form") parser.add_argument( 'assignment_file', type=FileStorage, @@ -22,7 +22,7 @@ help='Projects visibility for students', location="form" ) -parser.add_argument("archieved", type=bool, help='Projects', location="form") +parser.add_argument("archived", type=bool, help='Projects', location="form") parser.add_argument("test_path", type=str, help='Projects test path', location="form") parser.add_argument("script_name", type=str, help='Projects test script path', location="form") parser.add_argument( diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ed26f568..c394a85d 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -35,7 +35,7 @@ def get(self): return query_selected_from_model( Project, response_url, - select_values=["project_id", "title", "descriptions"], + select_values=["project_id", "title", "description"], url_mapper={"project_id": response_url}, filters=request.args ) @@ -58,10 +58,10 @@ def post(self): urljoin(f"{API_URL}/", "/projects"), required_fields=[ "title", - "descriptions", + "description", "course_id", "visible_for_students", - "archieved"] + "archived"] ) except SQLAlchemyError: return jsonify({"error": "Something went wrong while inserting into the database.", diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 5171e1e6..fad0b1a8 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -22,12 +22,12 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes __tablename__ = "projects" project_id: int = Column(Integer, primary_key=True) title: str = Column(String(50), nullable=False, unique=False) - descriptions: str = Column(Text, nullable=False) + description: str = Column(Text, nullable=False) assignment_file: str = Column(String(50)) deadline: str = Column(DateTime(timezone=True)) course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) visible_for_students: bool = Column(Boolean, nullable=False) - archieved: bool = Column(Boolean, nullable=False) + archived: bool = Column(Boolean, nullable=False) test_path: str = Column(String(50)) script_name: str = Column(String(50)) regex_expressions: list[str] = Column(ARRAY(String(50))) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 1861ec85..bb62ae0e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -31,11 +31,11 @@ def project(course): date = datetime(2024, 2, 25, 12, 0, 0) project = Project( title="Project", - descriptions="Test project", + description="Test project", course_id=course.course_id, deadline=date, visible_for_students=True, - archieved=False, + archived=False, test_path="testpad", script_name="testscript", regex_expressions='r' @@ -48,12 +48,12 @@ def project_json(project: Project): """A function that return the json data of a project including the PK neede for testing""" data = { "title": project.title, - "descriptions": project.descriptions, + "description": project.description, "assignment_file": project.assignment_file, "deadline": project.deadline, "course_id": project.course_id, "visible_for_students": project.visible_for_students, - "archieved": project.archieved, + "archived": project.archived, "test_path": project.test_path, "script_name": project.script_name, "regex_expressions": project.regex_expressions diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index b833a03d..906ca596 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -54,11 +54,14 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec project_json["course_id"] = course_ad.course_id # post the project + print(project_json) with open("testzip.zip", "rb") as zip_file: project_json["assignment_file"] = zip_file response = client.post("/projects", data=project_json) # check if the project with the id is present + print("joink") + print(response) project_id = response.json["data"]["project_id"] response = client.delete(f"/projects/{project_id}") @@ -87,14 +90,14 @@ def test_patch_project(db_session, client, course_ad, course_teacher_ad, project project_id = project.project_id new_title = "patched title" - new_archieved = not project.archieved + new_archived = not project.archived response = client.patch(f"/projects/{project_id}", json={ - "title": new_title, "archieved": new_archieved + "title": new_title, "archived": new_archived }) db_session.commit() updated_project = db_session.get(Project, {"project_id": project.project_id}) assert response.status_code == 200 assert updated_project.title == new_title - assert updated_project.archieved == new_archieved + assert updated_project.archived == new_archived diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 1a272500..dbc0dc19 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -76,9 +76,9 @@ def valid_project(): deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM project = Project( title="Project", - descriptions="Test project", + description="Test project", deadline=deadline, visible_for_students=True, - archieved=False, + archived=False, ) return project From 6333ae43b7d274118d7d73c61013842b88f8a042 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 12 Mar 2024 22:07:28 +0100 Subject: [PATCH 107/144] #15 - Spelling --- backend/project/endpoints/submissions.py | 2 +- backend/tests/endpoints/conftest.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 07e3d4f9..62d289ae 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -233,7 +233,7 @@ def patch(self, submission_id:int) -> dict[str, any]: return data, 500 def delete(self, submission_id: int) -> dict[str, any]: - """Delete a submission given an submission ID + """Delete a submission given a submission ID Args: submission_id (int): Submission ID diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index de15708a..77bf556e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -155,7 +155,7 @@ def session(db_session): @pytest.fixture def app(): - """A fixture that creates and configure a new app instance for each test. + """A fixture that creates and configures a new app instance for each test. Returns: Flask -- A Flask application instance """ @@ -196,7 +196,7 @@ def project(course): @pytest.fixture def project_json(project: Project): - """A function that return the json data of a project including the PK neede for testing""" + """A function that return the json data of a project including the PK needed for testing""" data = { "title": project.title, "descriptions": project.descriptions, From 3972bb110c0dae3fa4fea21a21ed9b645c2284f6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 12 Mar 2024 23:10:25 +0100 Subject: [PATCH 108/144] #15 - Fixing tests the merge broke --- backend/project/endpoints/index/OpenAPI_Object.json | 6 +++--- backend/project/models/project.py | 2 +- backend/tests/endpoints/conftest.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3b70d27d..829f7c38 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -58,7 +58,7 @@ "project_id": { "type": "int" }, - "descriptions": { + "description": { "type": "string" }, "title": { @@ -102,7 +102,7 @@ "schema": { "type": "object", "properties": { - "archieved": { + "archived": { "type": "bool" }, "assignment_file": { @@ -114,7 +114,7 @@ "deadline": { "type": "date" }, - "descriptions": { + "description": { "type": "array", "items": { "description": "string" diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 0bd200f1..0ed6c495 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -12,7 +12,7 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes an optional deadline, the course id of the course to which the project belongs, visible for students variable so a teacher can decide if the students can see it yet, - archieved var so we can implement the archiving functionality, + archived var so we can implement the archiving functionality, a test path,script name and regex expressions for automated testing Pylint disable too many instance attributes because we can't reduce the amount diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index b3f9cdaa..eed9da77 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -51,24 +51,24 @@ def projects(session): return [ Project( title="B+ Trees", - descriptions="Implement B+ trees", + description="Implement B+ trees", assignment_file="assignement.pdf", deadline=datetime(2024,3,15,13,0,0), course_id=course_id_ad3, visible_for_students=True, - archieved=False, + archived=False, test_path="/tests", script_name="script.sh", regex_expressions=["solution"] ), Project( title="Predicaten", - descriptions="Predicaten project", + description="Predicaten project", assignment_file="assignment.pdf", deadline=datetime(2023,3,15,13,0,0), course_id=course_id_raf, visible_for_students=False, - archieved=True, + archived=True, test_path="/tests", script_name="script.sh", regex_expressions=[".*"] From 7dc392306fd5d09ca10a5143c5201fc2c836d0ab Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Wed, 13 Mar 2024 12:58:14 +0100 Subject: [PATCH 109/144] Feature/share link (#72) * create subfolder for course tests * added table for share codes * loading env variables * added default value to function parameters * added endpoint for join_codes * added tests for join_codes * fixed: linting * fixed typo: * fixed import conftest * fixed merge with singular model naming * unused file --- backend/db_construct.sql | 22 ++++++++ backend/project/__init__.py | 2 + backend/project/db_in.py | 2 + .../endpoints/courses/courses_utils.py | 6 +- .../courses/join_codes/course_join_code.py | 48 ++++++++++++++++ .../courses/join_codes/course_join_codes.py | 55 +++++++++++++++++++ .../courses/join_codes/join_codes_config.py | 24 ++++++++ .../courses/join_codes/join_codes_utils.py | 14 +++++ backend/project/models/course_share_code.py | 22 ++++++++ backend/tests/endpoints/conftest.py | 15 ++++- .../endpoints/{ => course}/courses_test.py | 0 .../tests/endpoints/course/share_link_test.py | 53 ++++++++++++++++++ 12 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 backend/project/endpoints/courses/join_codes/course_join_code.py create mode 100644 backend/project/endpoints/courses/join_codes/course_join_codes.py create mode 100644 backend/project/endpoints/courses/join_codes/join_codes_config.py create mode 100644 backend/project/endpoints/courses/join_codes/join_codes_utils.py create mode 100644 backend/project/models/course_share_code.py rename backend/tests/endpoints/{ => course}/courses_test.py (100%) create mode 100644 backend/tests/endpoints/course/share_link_test.py diff --git a/backend/db_construct.sql b/backend/db_construct.sql index 8c42e382..e18f7782 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -14,6 +14,14 @@ CREATE TABLE courses ( PRIMARY KEY(course_id) ); +CREATE TABLE course_join_codes ( + join_code UUID DEFAULT gen_random_uuid() NOT NULL, + course_id INT NOT NULL, + expiry_time DATE, + for_admins BOOLEAN NOT NULL, + CONSTRAINT fk_course_join_link FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE, + PRIMARY KEY(join_code) +); CREATE TABLE course_admins ( course_id INT NOT NULL REFERENCES courses(course_id) ON DELETE CASCADE, @@ -55,3 +63,17 @@ CREATE TABLE submissions ( CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) ); + +CREATE OR REPLACE FUNCTION remove_expired_codes() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM course_join_codes + WHERE expiry_time < CURRENT_DATE; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER remove_expired_codes_trigger +AFTER INSERT OR UPDATE ON course_join_codes +FOR EACH ROW EXECUTE FUNCTION remove_expired_codes(); diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 67c05bcf..664ff947 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -9,6 +9,7 @@ from .endpoints.courses.courses_config import courses_bp from .endpoints.projects.project_endpoint import project_bp from .endpoints.submissions import submissions_bp +from .endpoints.courses.join_codes.join_codes_config import join_codes_bp def create_app(): """ @@ -23,6 +24,7 @@ def create_app(): app.register_blueprint(courses_bp) app.register_blueprint(project_bp) app.register_blueprint(submissions_bp) + app.register_blueprint(join_codes_bp) return app diff --git a/backend/project/db_in.py b/backend/project/db_in.py index 57a572fa..76534360 100644 --- a/backend/project/db_in.py +++ b/backend/project/db_in.py @@ -1,10 +1,12 @@ """db initialization""" import os +from dotenv import load_dotenv from flask_sqlalchemy import SQLAlchemy from sqlalchemy import URL db = SQLAlchemy() +load_dotenv() DATABSE_NAME = os.getenv("POSTGRES_DB") DATABASE_USER = os.getenv("POSTGRES_USER") diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index da496e0d..0489e775 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -213,7 +213,7 @@ def json_message(message): return {"message": message} -def get_course_abort_if_not_found(course_id): +def get_course_abort_if_not_found(course_id, url=f"{API_URL}/courses"): """ Get a course by its ID. @@ -224,11 +224,11 @@ def get_course_abort_if_not_found(course_id): Course: The course with the given ID. """ query = Course.query.filter_by(course_id=course_id) - course = execute_query_abort_if_db_error(query, f"{API_URL}/courses") + course = execute_query_abort_if_db_error(query, url) if not course: response = json_message("Course not found") - response["url"] = f"{API_URL}/courses" + response["url"] = url abort(404, description=response) return course diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py new file mode 100644 index 00000000..df952877 --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -0,0 +1,48 @@ +""" +This file will contain the api endpoints for the /courses//join_codes url +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask_restful import Resource +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model +from project.models.course_share_code import CourseShareCode +from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") + +class CourseJoinCode(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/join_codes url, only an admin of a course can do this + """ + + @check_course_exists + def get(self, course_id, join_code): + """ + This function will return all the join codes of a course + """ + + return query_by_id_from_model( + CourseShareCode, + "join_code", + join_code, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes") + ) + + @check_course_exists + def delete(self, course_id, join_code): + """ + Api endpoint for adding new join codes to a course, can only be done by the teacher + """ + + return delete_by_id_from_model( + CourseShareCode, + "join_code", + join_code, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes") + ) diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py new file mode 100644 index 00000000..7ab142b6 --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -0,0 +1,55 @@ +""" +This file will contain the api endpoints for the /courses//join_codes url +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask_restful import Resource +from flask import request +from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.models.course_share_code import CourseShareCode +from project.endpoints.courses.courses_utils import get_course_abort_if_not_found + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") + +class CourseJoinCodes(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/join_codes url, only an admin of a course can do this + """ + + def get(self, course_id): + """ + This function will return all the join codes of a course + """ + + get_course_abort_if_not_found(course_id) + + return query_selected_from_model( + CourseShareCode, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"), + select_values=["join_code", "expiry_time"], + filters={"course_id": course_id} + ) + + def post(self, course_id): + """ + Api endpoint for adding new join codes to a course, can only be done by the teacher + """ + + get_course_abort_if_not_found(course_id) + + data = request.get_json() + data["course_id"] = course_id + + return insert_into_model( + CourseShareCode, + data, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"), + "join_code", + required_fields=["for_admins"] + ) diff --git a/backend/project/endpoints/courses/join_codes/join_codes_config.py b/backend/project/endpoints/courses/join_codes/join_codes_config.py new file mode 100644 index 00000000..a2ae0bce --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/join_codes_config.py @@ -0,0 +1,24 @@ +""" +This file is used to configure the join codes endpoints. +It is used to define the routes for the join codes blueprint and the +corresponding api endpoints. + +The join codes blueprint is used to define the routes for the join codes api +endpoints and the join codes api is used to define the routes for the join codes +api endpoints. +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.courses.join_codes.course_join_codes import CourseJoinCodes +from project.endpoints.courses.join_codes.course_join_code import CourseJoinCode + +join_codes_bp = Blueprint("join_codes", __name__) +join_codes_api = Api(join_codes_bp) + +join_codes_bp.add_url_rule("/courses//join_codes", + view_func=CourseJoinCodes.as_view('course_join_codes')) + +join_codes_bp.add_url_rule("/courses//join_codes/", + view_func=CourseJoinCode.as_view('course_join_code')) diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py new file mode 100644 index 00000000..5078fce2 --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/join_codes_utils.py @@ -0,0 +1,14 @@ +""" +This module contains functions that are used by the join codes resources. +""" + +from project.endpoints.courses.courses_utils import get_course_abort_if_not_found + +def check_course_exists(func): + """ + Middleware to check if the course exists before handling the request + """ + def wrapper(self, course_id, join_code, *args, **kwargs): + get_course_abort_if_not_found(course_id) + return func(self, course_id, join_code, *args, **kwargs) + return wrapper diff --git a/backend/project/models/course_share_code.py b/backend/project/models/course_share_code.py new file mode 100644 index 00000000..67fbad92 --- /dev/null +++ b/backend/project/models/course_share_code.py @@ -0,0 +1,22 @@ +""" +Course Share Code Model +""" + + +from dataclasses import dataclass +import uuid +from sqlalchemy import Integer, Column, ForeignKey, Date, Boolean +from sqlalchemy.dialects.postgresql import UUID +from project import db + +@dataclass +class CourseShareCode(db.Model): + """ + This class will contain the model for the course share codes + """ + __tablename__ = "course_join_codes" + + join_code: int = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) + expiry_time: str = Column(Date, nullable=True) + for_admins: bool = Column(Boolean, nullable=False) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index eed9da77..7c71842e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,12 +6,14 @@ from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine -from project import create_app_with_db -from project.db_in import url, db + from project.models.course import Course from project.models.user import User from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin +from project.models.course_share_code import CourseShareCode +from project import create_app_with_db +from project.db_in import url, db from project.models.submission import Submission def users(): @@ -299,3 +301,12 @@ def course(course_teacher): """A course for testing, with the course teacher as the teacher.""" sel2 = Course(name="Sel2", teacher=course_teacher.uid) return sel2 + +@pytest.fixture +def share_code_admin(db_with_course): + """A course with share codes for testing.""" + course = db_with_course.query(Course).first() + share_code = CourseShareCode(course_id=course.course_id, for_admins=True) + db_with_course.add(share_code) + db_with_course.commit() + return share_code diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/course/courses_test.py similarity index 100% rename from backend/tests/endpoints/courses_test.py rename to backend/tests/endpoints/course/courses_test.py diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py new file mode 100644 index 00000000..85f3346e --- /dev/null +++ b/backend/tests/endpoints/course/share_link_test.py @@ -0,0 +1,53 @@ +""" +This file contains the tests for the share link endpoints of the course resource. +""" + +from project.models.course import Course + +class TestCourseShareLinks: + """ + Class that will respond to the /courses/course_id/students link + teachers should be able to assign and remove students from courses, + and everyone should be able to list all students assigned to a course + """ + + def test_get_share_links(self, db_with_course, client): + """Test whether the share links are accessible""" + example_course = db_with_course.query(Course).first() + response = client.get(f"courses/{example_course.course_id}/join_codes") + assert response.status_code == 200 + + def test_post_share_links(self, db_with_course, client): + """Test whether the share links are accessible to post to""" + example_course = db_with_course.query(Course).first() + response = client.post( + f"courses/{example_course.course_id}/join_codes", + json={"for_admins": True}) + assert response.status_code == 201 + + def test_delete_share_links(self, share_code_admin, client): + """Test whether the share links are accessible to delete""" + response = client.delete( + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}") + assert response.status_code == 200 + + def test_get_share_links_404(self, client): + """Test whether the share links are accessible""" + response = client.get("courses/0/join_codes") + assert response.status_code == 404 + + def test_post_share_links_404(self, client): + """Test whether the share links are accessible to post to""" + response = client.post("courses/0/join_codes", json={"for_admins": True}) + assert response.status_code == 404 + + def test_delete_share_links_404(self, client): + """Test whether the share links are accessible to delete""" + response = client.delete("courses/0/join_codes/0") + assert response.status_code == 404 + + def test_for_admins_required(self, db_with_course, client): + """Test whether the for_admins field is required""" + example_course = db_with_course.query(Course).first() + response = client.post(f"courses/{example_course.course_id}/join_codes", json={}) + assert response.status_code == 400 From 88aef976fb929928c2fdc63764be700ea807615e Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:01:27 +0100 Subject: [PATCH 110/144] added functionlity for downloading files --- backend/project/__main__.py | 2 ++ .../projects/project_assignment_file.py | 32 +++++++++++++++++++ .../endpoints/projects/project_detail.py | 2 +- .../endpoints/projects/project_endpoint.py | 6 ++++ .../project/endpoints/projects/projects.py | 2 +- 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 backend/project/endpoints/projects/project_assignment_file.py diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 444d1410..a1bdd3bc 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,5 +1,7 @@ """Main entry point for the application.""" +import sys +sys.path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py new file mode 100644 index 00000000..bc360c3d --- /dev/null +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -0,0 +1,32 @@ +""" +Module for getting the assignment files +of a project +""" +import os +from urllib.parse import urljoin + +from flask import jsonify, send_from_directory, send_file +from werkzeug.utils import safe_join + +from flask_restful import Resource + +from project.models.project import Project +from project.utils.query_agent import query_by_id_from_model + +API_URL = os.getenv('API_HOST') +RESPONSE_URL = urljoin(API_URL, "projects") +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + +class ProjectAssignmentFiles(Resource): + """ + Class for getting the assignment files of a project + """ + def get(self, project_id): + + project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + + file_url = urljoin(f"{UPLOAD_FOLDER}", f"{project_id}") + "/" + + directory = safe_join(os.getcwd(), file_url) + + return send_from_directory(directory, project.assignment_file, as_attachment=True) \ No newline at end of file diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 85d7b99c..fc2008aa 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -14,10 +14,10 @@ patch_by_id_from_model - API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") + class ProjectDetail(Resource): """ Class for projects/id endpoints diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index 09938878..0c4eee20 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -7,6 +7,7 @@ from project.endpoints.projects.projects import ProjectsEndpoint from project.endpoints.projects.project_detail import ProjectDetail +from project.endpoints.projects.project_assignment_file import ProjectAssignmentFiles project_bp = Blueprint('project_endpoint', __name__) @@ -19,3 +20,8 @@ '/projects/', view_func=ProjectDetail.as_view('project_detail') ) + +project_bp.add_url_rule( + '/projects//assignments', + view_func=ProjectAssignmentFiles.as_view('project_assignments') +) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index c394a85d..88752808 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -30,7 +30,6 @@ def get(self): Get method for listing all available projects that are currently in the API """ - response_url = urljoin(API_URL, "projects") return query_selected_from_model( Project, @@ -49,6 +48,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() filename = os.path.split(file.filename)[1] + project_json["assignment_file"] = filename # save the file that is given with the request try: From c700de177170deda37a17856822d5dedb0abc1b6 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:02:21 +0100 Subject: [PATCH 111/144] reformatting --- backend/project/endpoints/projects/project_assignment_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index bc360c3d..9bdc5b11 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -25,7 +25,7 @@ def get(self, project_id): project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - file_url = urljoin(f"{UPLOAD_FOLDER}", f"{project_id}") + "/" + file_url = safe_join(f"{UPLOAD_FOLDER}", f"{project_id}") directory = safe_join(os.getcwd(), file_url) From 8582a6c88ba4fb94bfa7682570bb942d70f3c8fe Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:09:37 +0100 Subject: [PATCH 112/144] linter --- backend/project/__main__.py | 4 ++-- .../project/endpoints/projects/project_assignment_file.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index a1bdd3bc..eaed97f7 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,7 +1,7 @@ """Main entry point for the application.""" -import sys +# import sys -sys.path.append(".") +# sys.path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 9bdc5b11..d405fc8b 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,13 +5,12 @@ import os from urllib.parse import urljoin -from flask import jsonify, send_from_directory, send_file +from flask import send_from_directory from werkzeug.utils import safe_join from flask_restful import Resource from project.models.project import Project -from project.utils.query_agent import query_by_id_from_model API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -22,6 +21,9 @@ class ProjectAssignmentFiles(Resource): Class for getting the assignment files of a project """ def get(self, project_id): + """ + Get the assignment files of a project + """ project = Project.query.filter(getattr(Project, "project_id") == project_id).first() @@ -29,4 +31,4 @@ def get(self, project_id): directory = safe_join(os.getcwd(), file_url) - return send_from_directory(directory, project.assignment_file, as_attachment=True) \ No newline at end of file + return send_from_directory(directory, project.assignment_file, as_attachment=True) From cab65e1ddc6c5bd4e4c3f08036fe828435240253 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:13:26 +0100 Subject: [PATCH 113/144] niets veranderd? linter flipt --- backend/project/__main__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index eaed97f7..444d1410 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,7 +1,5 @@ """Main entry point for the application.""" -# import sys -# sys.path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url From 695514ece2134aa90a08c826c1c48340627aec1e Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:17:12 +0100 Subject: [PATCH 114/144] small newline change --- backend/project/endpoints/projects/project_detail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index fc2008aa..df4e99d7 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -17,7 +17,6 @@ API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") - class ProjectDetail(Resource): """ Class for projects/id endpoints From 0e5bc5975f41cdc6cfa1bfc9ca0a613e86dff90d Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:54:00 +0100 Subject: [PATCH 115/144] removed f-string formatting --- backend/project/endpoints/projects/project_assignment_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index d405fc8b..108ac9e6 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -27,7 +27,7 @@ def get(self, project_id): project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - file_url = safe_join(f"{UPLOAD_FOLDER}", f"{project_id}") + file_url = safe_join(UPLOAD_FOLDER, project_id) directory = safe_join(os.getcwd(), file_url) From 5514ca26b62f8397400c7156a74d9a50c51aa994 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:56:25 +0100 Subject: [PATCH 116/144] added 404 and 500 cases --- .../endpoints/projects/project_assignment_file.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 108ac9e6..14f7d014 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -24,9 +24,18 @@ def get(self, project_id): """ Get the assignment files of a project """ - - project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - + try: + project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + if project is None: + return { + "message": "Project not found", + "url": RESPONSE_URL, + }, 404 + except SQLAlchemyError: + return { + "message": "Something went wrong querying the project", + "url": RESPONSE_URL + }, 500 file_url = safe_join(UPLOAD_FOLDER, project_id) directory = safe_join(os.getcwd(), file_url) From 5e37b9fa1dccd4c294c9e379ba40c9d23fc83fcd Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 21:44:12 +0100 Subject: [PATCH 117/144] fixed tests --- .../projects/project_assignment_file.py | 2 +- .../project/endpoints/projects/projects.py | 3 +- backend/tests/endpoints/project_test.py | 36 +++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 14f7d014..e81239e3 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -36,7 +36,7 @@ def get(self, project_id): "message": "Something went wrong querying the project", "url": RESPONSE_URL }, 500 - file_url = safe_join(UPLOAD_FOLDER, project_id) + file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") directory = safe_join(os.getcwd(), file_url) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 88752808..c30c0886 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -63,7 +63,8 @@ def post(self): "visible_for_students", "archived"] ) - except SQLAlchemyError: + except SQLAlchemyError as e: + print(e) return jsonify({"error": "Something went wrong while inserting into the database.", "url": f"{API_URL}/projects"}), 500 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 906ca596..f15fc9cd 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,6 +1,40 @@ """Tests for project endpoints.""" from project.models.project import Project +def test_assignment_download(db_session, client, course_ad, course_teacher_ad, project_json): + """ + Method for assignment download + """ + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + project_json["course_id"] = course_ad.course_id + + with open("testzip.zip", "rb") as zip_file: + project_json["assignment_file"] = zip_file + # post the project + response = client.post( + "/projects", + data=project_json, + content_type='multipart/form-data' + ) + + project_id = response.json["data"]["project_id"] + response = client.get(f"/projects/{project_id}/assignments") + # file downloaded succesfully + assert response.status_code == 200 + + +def test_not_found_download(db_session, client, project_json): + response = client.get("/projects") + # get an index that doesnt exist + project_id = len(response.data)+1 + response = client.get(f"/projects/{project_id}/assignments") + assert response.status_code == 404 + + def test_projects_home(client): """Test home project endpoint.""" response = client.get("/projects") @@ -60,8 +94,6 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec response = client.post("/projects", data=project_json) # check if the project with the id is present - print("joink") - print(response) project_id = response.json["data"]["project_id"] response = client.delete(f"/projects/{project_id}") From 54a3e1ea6be0d88bd5875fd5acfc5f2749c14ebc Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 22:29:16 +0100 Subject: [PATCH 118/144] fixed os.curcwd --- .../project/endpoints/projects/project_assignment_file.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index e81239e3..606f24ea 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,7 +5,7 @@ import os from urllib.parse import urljoin -from flask import send_from_directory +from flask import send_from_directory, send_file from werkzeug.utils import safe_join from flask_restful import Resource @@ -38,6 +38,5 @@ def get(self, project_id): }, 500 file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") - directory = safe_join(os.getcwd(), file_url) - - return send_from_directory(directory, project.assignment_file, as_attachment=True) + # return send_from_directory(directory, project.assignment_file, as_attachment=True) + return send_from_directory(file_url,project.assignment_file) From 070ec2ebccf762c869cd3e92c9bbe4934cfbf9a2 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 22:31:45 +0100 Subject: [PATCH 119/144] linter --- .../project/endpoints/projects/project_assignment_file.py | 3 ++- backend/tests/endpoints/project_test.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 606f24ea..80c6f54e 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,8 +5,9 @@ import os from urllib.parse import urljoin -from flask import send_from_directory, send_file +from flask import send_from_directory from werkzeug.utils import safe_join +from sqlalchemy.exc import SQLAlchemyError from flask_restful import Resource diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index f15fc9cd..f8949ff6 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -27,7 +27,10 @@ def test_assignment_download(db_session, client, course_ad, course_teacher_ad, p assert response.status_code == 200 -def test_not_found_download(db_session, client, project_json): +def test_not_found_download(client): + """ + Test a not present project download + """ response = client.get("/projects") # get an index that doesnt exist project_id = len(response.data)+1 From 5c719883569474a5b2fc53d0c4b88371e35b308f Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:20:49 +0100 Subject: [PATCH 120/144] removed print --- backend/project/endpoints/projects/projects.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index c30c0886..88752808 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -63,8 +63,7 @@ def post(self): "visible_for_students", "archived"] ) - except SQLAlchemyError as e: - print(e) + except SQLAlchemyError: return jsonify({"error": "Something went wrong while inserting into the database.", "url": f"{API_URL}/projects"}), 500 From 9a0d99dd5eb87adfecc34370bdceb8f8ec6a093a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:26:22 +0100 Subject: [PATCH 121/144] check if file exist and commented code removed --- .../endpoints/projects/project_assignment_file.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 80c6f54e..9e8f2973 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -37,7 +37,14 @@ def get(self, project_id): "message": "Something went wrong querying the project", "url": RESPONSE_URL }, 500 + file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") - # return send_from_directory(directory, project.assignment_file, as_attachment=True) + if not os.path.isfile(file_url): + # no file is found so return 404 + return { + "message": "No assignment file found for this project", + "url": file_url + }, 404 + return send_from_directory(file_url,project.assignment_file) From 13e32fc7410f6b161c65fd2a36c6b8895e73acfa Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:43:07 +0100 Subject: [PATCH 122/144] fixed code duplication --- .../projects/project_assignment_file.py | 17 ++++++++++++----- backend/project/utils/query_agent.py | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 9e8f2973..5ab1b60e 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -12,6 +12,7 @@ from flask_restful import Resource from project.models.project import Project +from project.utils.query_agent import query_by_id_from_model API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -25,8 +26,9 @@ def get(self, project_id): """ Get the assignment files of a project """ - try: - project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + '''try: + # project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + project = query_by_id_from_mode(Project, "project_id", project_id, f"RESPONSE_URL/{project_id}/assignments") if project is None: return { "message": "Project not found", @@ -36,15 +38,20 @@ def get(self, project_id): return { "message": "Something went wrong querying the project", "url": RESPONSE_URL - }, 500 + }, 500''' + json, status_code = query_by_id_from_model(Project, "project_id", project_id, f"RESPONSE_URL") + if status_code != 200: + return json, status_code + + project = json["data"] file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") - if not os.path.isfile(file_url): + if not os.path.isfile(safe_join(file_url, project.assignment_file)): # no file is found so return 404 return { "message": "No assignment file found for this project", "url": file_url }, 404 - return send_from_directory(file_url,project.assignment_file) + return send_from_directory(file_url, project.assignment_file) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index bcdf1ea0..745006a1 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -191,10 +191,10 @@ def query_by_id_from_model(model: DeclarativeMeta, result: Query = model.query.filter(getattr(model, column_name) == column_id).first() if not result: return {"message": "Resource not found", "url": base_url}, 404 - return jsonify({ + return { "data": result, "message": "Resource fetched correctly", - "url": urljoin(f"{base_url}/", str(column_id))}), 200 + "url": urljoin(f"{base_url}/", str(column_id))}, 200 except SQLAlchemyError: return { "error": "Something went wrong while querying the database.", From 0045e1f9d324bbe706b5e17ef1ee4099afa4e39a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:50:34 +0100 Subject: [PATCH 123/144] used basename insteal of .split --- .../endpoints/projects/project_assignment_file.py | 15 +-------------- backend/project/endpoints/projects/projects.py | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 5ab1b60e..4ed8b20d 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -26,26 +26,13 @@ def get(self, project_id): """ Get the assignment files of a project """ - '''try: - # project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - project = query_by_id_from_mode(Project, "project_id", project_id, f"RESPONSE_URL/{project_id}/assignments") - if project is None: - return { - "message": "Project not found", - "url": RESPONSE_URL, - }, 404 - except SQLAlchemyError: - return { - "message": "Something went wrong querying the project", - "url": RESPONSE_URL - }, 500''' json, status_code = query_by_id_from_model(Project, "project_id", project_id, f"RESPONSE_URL") if status_code != 200: return json, status_code project = json["data"] - file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") + file_url = safe_join(UPLOAD_FOLDER, str(project_id)) if not os.path.isfile(safe_join(file_url, project.assignment_file)): # no file is found so return 404 diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 88752808..eabd29f9 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -47,7 +47,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() - filename = os.path.split(file.filename)[1] + filename = os.path.basename(file.filename) project_json["assignment_file"] = filename # save the file that is given with the request From 3fbb041d7ee4fbabe24c2f1784fc34603a9ed74b Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:56:15 +0100 Subject: [PATCH 124/144] linter fix --- .../project/endpoints/projects/project_assignment_file.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 4ed8b20d..61447c94 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -7,7 +7,6 @@ from flask import send_from_directory from werkzeug.utils import safe_join -from sqlalchemy.exc import SQLAlchemyError from flask_restful import Resource @@ -26,7 +25,12 @@ def get(self, project_id): """ Get the assignment files of a project """ - json, status_code = query_by_id_from_model(Project, "project_id", project_id, f"RESPONSE_URL") + json, status_code = query_by_id_from_model( + Project, + "project_id", + project_id, + "RESPONSE_URL" + ) if status_code != 200: return json, status_code From a2e9c32e823643c56fd083644be0fe71e352462c Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:42:52 +0100 Subject: [PATCH 125/144] Backend/tests/review models (#83) * #81 - Updated user model tests, TODO delete * #81 - Removing an unnecessary file * #81 - Updated course model tests, TODO delete * #81 - Updated course_relation model tests * #81 - Cleanup * #81 - Project model tests * #81 - Submission model tests * #81 - Cleanup * #81 - Adding a test and also testing GH tests out * #81 - Adding rollbacks after every error raised * #81 - Maybe fix * #81 - Another try * #81 - This should fix it * #81 Adding the delete method tests and updating the db_construct --- backend/db_construct.sql | 4 +- backend/project/models/course_relation.py | 12 +- backend/project/models/project.py | 6 +- backend/project/models/submission.py | 2 +- backend/project/models/user.py | 12 +- backend/tests/conftest.py | 134 +++++++++++++++- backend/tests/endpoints/conftest.py | 121 +------------- backend/tests/models/__index__.py | 0 backend/tests/models/conftest.py | 84 ---------- backend/tests/models/course_relation_test.py | 83 ++++++++++ backend/tests/models/course_test.py | 149 +++++++----------- backend/tests/models/project_test.py | 79 ++++++++++ .../models/projects_and_submissions_test.py | 63 -------- backend/tests/models/submission_test.py | 92 +++++++++++ backend/tests/models/user_test.py | 57 +++++++ backend/tests/models/users_test.py | 25 --- 16 files changed, 520 insertions(+), 403 deletions(-) delete mode 100644 backend/tests/models/__index__.py delete mode 100644 backend/tests/models/conftest.py create mode 100644 backend/tests/models/course_relation_test.py create mode 100644 backend/tests/models/project_test.py delete mode 100644 backend/tests/models/projects_and_submissions_test.py create mode 100644 backend/tests/models/submission_test.py create mode 100644 backend/tests/models/user_test.py delete mode 100644 backend/tests/models/users_test.py diff --git a/backend/db_construct.sql b/backend/db_construct.sql index e18f7782..a1ad51fe 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -10,7 +10,7 @@ CREATE TABLE courses ( name VARCHAR(50) NOT NULL, ufora_id VARCHAR(50), teacher VARCHAR(255) NOT NULL, - CONSTRAINT fk_teacher FOREIGN KEY(teacher) REFERENCES users(uid), + CONSTRAINT fk_teacher FOREIGN KEY(teacher) REFERENCES users(uid) ON DELETE CASCADE, PRIMARY KEY(course_id) ); @@ -61,7 +61,7 @@ CREATE TABLE submissions ( submission_status BOOLEAN NOT NULL, PRIMARY KEY(submission_id), CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, - CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) + CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) ON DELETE CASCADE ); CREATE OR REPLACE FUNCTION remove_expired_codes() diff --git a/backend/project/models/course_relation.py b/backend/project/models/course_relation.py index 41b7d40b..7c27af31 100644 --- a/backend/project/models/course_relation.py +++ b/backend/project/models/course_relation.py @@ -1,5 +1,6 @@ -"""Models for relation between users and courses""" -from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String +"""Course relation model""" + +from sqlalchemy import Integer, Column, ForeignKey, String from project.db_in import db class BaseCourseRelation(db.Model): @@ -10,11 +11,8 @@ class BaseCourseRelation(db.Model): __abstract__ = True - course_id = Column(Integer, ForeignKey('courses.course_id'), nullable=False) - uid = Column(String(255), ForeignKey("users.uid"), nullable=False) - __table_args__ = ( - PrimaryKeyConstraint("course_id", "uid"), - ) + course_id = Column(Integer, ForeignKey('courses.course_id'), primary_key=True) + uid = Column(String(255), ForeignKey("users.uid"), primary_key=True) class CourseAdmin(BaseCourseRelation): """Admin to course relation model""" diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 0ed6c495..8ba901ff 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -1,10 +1,10 @@ -"""Model for projects""" -import dataclasses +"""Project model""" +from dataclasses import dataclass from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project.db_in import db -@dataclasses.dataclass +@dataclass class Project(db.Model): # pylint: disable=too-many-instance-attributes """This class describes the projects table, a projects has an id, a title, a description, diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index e2309eea..cda2620d 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -1,4 +1,4 @@ -"""Model for submissions""" +"""Submission model""" from dataclasses import dataclass from sqlalchemy import Column, String, ForeignKey, Integer, CheckConstraint, DateTime, Boolean diff --git a/backend/project/models/user.py b/backend/project/models/user.py index 7597462b..bb130349 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -1,10 +1,10 @@ -"""Model for users""" -import dataclasses +"""User model""" +from dataclasses import dataclass from sqlalchemy import Boolean, Column, String from project.db_in import db -@dataclasses.dataclass +@dataclass class User(db.Model): """This class defines the users table, a user has a uid, @@ -12,6 +12,6 @@ class User(db.Model): can be either a student,admin or teacher""" __tablename__ = "users" - uid:str = Column(String(255), primary_key=True) - is_teacher:bool = Column(Boolean) - is_admin:bool = Column(Boolean) + uid: str = Column(String(255), primary_key=True) + is_teacher: bool = Column(Boolean) + is_admin: bool = Column(Boolean) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0ff5b009..aebe7ce9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,15 @@ -"""root level fixtures""" +"""Root level fixtures""" + +from datetime import datetime +from zoneinfo import ZoneInfo import pytest from project.sessionmaker import engine, Session from project.db_in import db +from project.models.course import Course +from project.models.user import User +from project.models.project import Project +from project.models.course_relation import CourseStudent,CourseAdmin +from project.models.submission import Submission @pytest.fixture def db_session(): @@ -22,3 +30,127 @@ def db_session(): for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() + +def users(): + """Return a list of users to populate the database""" + return [ + User(uid="brinkmann", is_admin=True, is_teacher=True), + User(uid="laermans", is_admin=True, is_teacher=True), + User(uid="student01", is_admin=False, is_teacher=False), + User(uid="student02", is_admin=False, is_teacher=False) + ] + +def courses(): + """Return a list of courses to populate the database""" + return [ + Course(name="AD3", teacher="brinkmann"), + Course(name="RAF", teacher="laermans"), + ] + +def course_relations(session): + """Returns a list of course relations to populate the database""" + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id + + return [ + CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), + CourseStudent(course_id=course_id_ad3, uid="student01"), + CourseStudent(course_id=course_id_ad3, uid="student02"), + CourseAdmin(course_id=course_id_raf, uid="laermans"), + CourseStudent(course_id=course_id_raf, uid="student02") + ] + +def projects(session): + """Return a list of projects to populate the database""" + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id + + return [ + Project( + title="B+ Trees", + description="Implement B+ trees", + assignment_file="assignement.pdf", + deadline=datetime(2024,3,15,13,0,0), + course_id=course_id_ad3, + visible_for_students=True, + archived=False, + test_path="/tests", + script_name="script.sh", + regex_expressions=["solution"] + ), + Project( + title="Predicaten", + description="Predicaten project", + assignment_file="assignment.pdf", + deadline=datetime(2023,3,15,13,0,0), + course_id=course_id_raf, + visible_for_students=False, + archived=True, + test_path="/tests", + script_name="script.sh", + regex_expressions=[".*"] + ) + ] + +def submissions(session): + """Return a list of submissions to populate the database""" + project_id_ad3 = session.query(Project).filter_by(title="B+ Trees").first().project_id + project_id_raf = session.query(Project).filter_by(title="Predicaten").first().project_id + + return [ + Submission( + uid="student01", + project_id=project_id_ad3, + grading=16, + submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), + submission_path="/submissions/1", + submission_status=True + ), + Submission( + uid="student02", + project_id=project_id_ad3, + submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), + submission_path="/submissions/2", + submission_status=False + ), + Submission( + uid="student02", + project_id=project_id_raf, + grading=15, + submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), + submission_path="/submissions/3", + submission_status=True + ) + ] + +@pytest.fixture +def session(): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" + + db.metadata.create_all(engine) + session = Session() + + try: + # Populate the database + session.add_all(users()) + session.commit() + session.add_all(courses()) + session.commit() + session.add_all(course_relations(session)) + session.commit() + session.add_all(projects(session)) + session.commit() + session.add_all(submissions(session)) + session.commit() + + yield session + finally: + # Rollback + session.rollback() + session.close() + + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 7c71842e..e87aed95 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,110 +3,15 @@ import tempfile import os from datetime import datetime -from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine - -from project.models.course import Course +from project import create_app_with_db +from project.db_in import db, url from project.models.user import User -from project.models.project import Project +from project.models.course import Course from project.models.course_relation import CourseStudent,CourseAdmin from project.models.course_share_code import CourseShareCode -from project import create_app_with_db -from project.db_in import url, db -from project.models.submission import Submission - -def users(): - """Return a list of users to populate the database""" - return [ - User(uid="brinkmann", is_admin=True, is_teacher=True), - User(uid="laermans", is_admin=True, is_teacher=True), - User(uid="student01", is_admin=False, is_teacher=False), - User(uid="student02", is_admin=False, is_teacher=False) - ] - -def courses(): - """Return a list of courses to populate the database""" - return [ - Course(name="AD3", teacher="brinkmann"), - Course(name="RAF", teacher="laermans"), - ] - -def course_relations(session): - """Returns a list of course relations to populate the database""" - course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id - course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id - - return [ - CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), - CourseStudent(course_id=course_id_ad3, uid="student01"), - CourseStudent(course_id=course_id_ad3, uid="student02"), - CourseAdmin(course_id=course_id_raf, uid="laermans"), - CourseStudent(course_id=course_id_raf, uid="student02") - ] - -def projects(session): - """Return a list of projects to populate the database""" - course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id - course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id - - return [ - Project( - title="B+ Trees", - description="Implement B+ trees", - assignment_file="assignement.pdf", - deadline=datetime(2024,3,15,13,0,0), - course_id=course_id_ad3, - visible_for_students=True, - archived=False, - test_path="/tests", - script_name="script.sh", - regex_expressions=["solution"] - ), - Project( - title="Predicaten", - description="Predicaten project", - assignment_file="assignment.pdf", - deadline=datetime(2023,3,15,13,0,0), - course_id=course_id_raf, - visible_for_students=False, - archived=True, - test_path="/tests", - script_name="script.sh", - regex_expressions=[".*"] - ) - ] - -def submissions(session): - """Return a list of submissions to populate the database""" - project_id_ad3 = session.query(Project).filter_by(title="B+ Trees").first().project_id - project_id_raf = session.query(Project).filter_by(title="Predicaten").first().project_id - - return [ - Submission( - uid="student01", - project_id=project_id_ad3, - grading=16, - submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), - submission_path="/submissions/1", - submission_status=True - ), - Submission( - uid="student02", - project_id=project_id_ad3, - submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), - submission_path="/submissions/2", - submission_status=False - ), - Submission( - uid="student02", - project_id=project_id_raf, - grading=15, - submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), - submission_path="/submissions/3", - submission_status=True - ) - ] +from project.models.project import Project @pytest.fixture def file_empty(): @@ -137,24 +42,6 @@ def files(): with open(name02, "rb") as temp02: yield [(temp01, name01), (temp02, name02)] -@pytest.fixture -def session(db_session): - """Create a database session for the tests""" - # Populate the database - db_session.add_all(users()) - db_session.commit() - db_session.add_all(courses()) - db_session.commit() - db_session.add_all(course_relations(db_session)) - db_session.commit() - db_session.add_all(projects(db_session)) - db_session.commit() - db_session.add_all(submissions(db_session)) - db_session.commit() - - # Tests can now use a populated database - yield db_session - @pytest.fixture def app(): """A fixture that creates and configures a new app instance for each test. diff --git a/backend/tests/models/__index__.py b/backend/tests/models/__index__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py deleted file mode 100644 index dbc0dc19..00000000 --- a/backend/tests/models/conftest.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Configuration for the models tests. Contains all the fixtures needed for multiple models tests. -""" - -from datetime import datetime -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -import pytest -from project.models.course import Course -from project.models.course_relation import CourseAdmin, CourseStudent -from project.models.project import Project -from project.models.user import User -from project.db_in import url - -engine = create_engine(url) -Session = sessionmaker(bind=engine) - - -@pytest.fixture -def valid_user(): - """A valid user for testing""" - user = User(uid="student", is_teacher=False, is_admin=False) - return user - -@pytest.fixture -def teachers(): - """A list of 10 teachers for testing""" - users = [User(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] - return users - -@pytest.fixture -def course_teacher(): - """A user that's a teacher for for testing""" - sel2_teacher = User(uid="Bart", is_teacher=True, is_admin=False) - return sel2_teacher - -@pytest.fixture -def course(course_teacher): - """A course for testing, with the course teacher as the teacher.""" - sel2 = Course(name="Sel2", teacher=course_teacher.uid) - return sel2 - -@pytest.fixture -def course_students(): - """A list of 5 students for testing.""" - students = [ - User(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) - for i in range(5) - ] - return students - -@pytest.fixture -def course_students_relation(course,course_students): - """A list of 5 course relations for testing.""" - course_relations = [ - CourseStudent(course_id=course.course_id, uid=course_students[i].uid) - for i in range(5) - ] - return course_relations - -@pytest.fixture -def assistent(): - """An assistent for testing.""" - assist = User(uid="assistent_sel2") - return assist - -@pytest.fixture() -def course_admin(course,assistent): - """A course admin for testing.""" - admin_relation = CourseAdmin(uid=assistent.uid, course_id=course.course_id) - return admin_relation - -@pytest.fixture() -def valid_project(): - """A valid project for testing.""" - deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM - project = Project( - title="Project", - description="Test project", - deadline=deadline, - visible_for_students=True, - archived=False, - ) - return project diff --git a/backend/tests/models/course_relation_test.py b/backend/tests/models/course_relation_test.py new file mode 100644 index 00000000..ba8fa238 --- /dev/null +++ b/backend/tests/models/course_relation_test.py @@ -0,0 +1,83 @@ +"""Course relation tests""" + +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.course import Course +from project.models.course_relation import CourseAdmin + +class TestCourseRelationModel: + """Class to test the CourseRelation model""" + + def test_create_course_relation(self, session: Session): + """Test if a course relation can be created""" + course_id = session.query(Course).filter_by(name="AD3").first().course_id + relation = CourseAdmin(course_id=course_id, uid="laermans") + session.add(relation) + session.commit() + assert session.get(CourseAdmin, (course_id, "laermans")) is not None + + def test_query_course_relation(self, session: Session): + """Test if a course relation can be queried""" + assert session.query(CourseAdmin).count() == 2 + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + assert relation is not None + assert relation.course_id == \ + session.query(Course).filter_by(name="AD3").first().course_id + + def test_update_course_relation(self, session: Session): + """Test if a course relation can be updated""" + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + course = session.query(Course).filter_by(name="RAF").first() + relation.course_id = course.course_id + session.commit() + assert session.get(CourseAdmin, (course.course_id, "brinkmann")) is not None + + def test_delete_course_relation(self, session: Session): + """Test if a course relation can be deleted""" + relation = session.query(CourseAdmin).first() + session.delete(relation) + session.commit() + assert session.get(CourseAdmin, (relation.course_id,relation.uid)) is None + assert session.query(CourseAdmin).count() == 1 + + def test_primary_key(self, session: Session): + """Test the primary key""" + relations = session.query(CourseAdmin).all() + with raises(IntegrityError): + relations[0].course_id = relations[1].course_id + relations[0].uid = relations[1].uid + session.commit() + session.rollback() + + def test_foreign_key_course_id(self, session: Session): + """Test the foreign key course_id""" + course = session.query(Course).filter_by(name="RAF").first() + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + relation.course_id = course.course_id + session.commit() + assert session.get(CourseAdmin, (course.course_id, "brinkmann")) is not None + with raises(IntegrityError): + relation.course_id = 0 + session.commit() + session.rollback() + + def test_foreign_key_uid(self, session: Session): + """Test the foreign key uid""" + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + relation.uid = "laermans" + session.commit() + assert session.get(CourseAdmin, (relation.course_id,relation.uid)) is not None + with raises(IntegrityError): + relation.uid = "unknown" + session.commit() + session.rollback() + + @mark.parametrize("property_name", ["course_id", "uid"]) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + relation = session.query(CourseAdmin).first() + with raises(IntegrityError): + setattr(relation, property_name, None) + session.commit() + session.rollback() diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py index 6c286d7c..3c17ecad 100644 --- a/backend/tests/models/course_test.py +++ b/backend/tests/models/course_test.py @@ -1,98 +1,59 @@ -"""Test module for the Course model""" -import pytest +"""Course model tests""" + +from pytest import raises, mark +from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from project.models.course import Course -from project.models.user import User -from project.models.course_relation import CourseAdmin, CourseStudent - class TestCourseModel: - """Test class for the database models""" - - def test_foreignkey_courses_teacher(self, db_session, course: Course): - """Tests the foreign key relation between courses and the teacher uid""" - with pytest.raises( - IntegrityError - ): - db_session.add(course) - db_session.commit() - - def test_correct_course(self, db_session, course: Course, course_teacher: User): - """Tests wether added course and a teacher are correctly connected""" - db_session.add(course_teacher) - db_session.commit() - - db_session.add(course) - db_session.commit() - assert ( - db_session.query(Course).filter_by(name=course.name).first().teacher - == course_teacher.uid - ) - - def test_foreignkey_coursestudents_uid( - self, db_session, course, course_teacher, course_students_relation - ): - """Test the foreign key of the CourseStudent related to the student uid""" - db_session.add(course_teacher) - db_session.commit() - - db_session.add(course) - db_session.commit() - for s in course_students_relation: - s.course_id = course.course_id - - with pytest.raises( - IntegrityError - ): - db_session.add_all(course_students_relation) - db_session.commit() - - def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arguments are needed for the test - self, - db_session, - course, - course_teacher, - course_students, - course_students_relation, - assistent, - course_admin, - ): - """Tests if we get the expected results for - correct usage of CourseStudent and CourseAdmin""" - - db_session.add(course_teacher) - db_session.commit() - - db_session.add(course) - db_session.commit() - - db_session.add_all(course_students) - db_session.commit() - - for s in course_students_relation: - s.course_id = course.course_id - db_session.add_all(course_students_relation) - db_session.commit() - - student_check = [ - s.uid - for s in db_session.query(CourseStudent) - .filter_by(course_id=course.course_id) - .all() - ] - student_uids = [s.uid for s in course_students] - assert student_check == student_uids - - db_session.add(assistent) - db_session.commit() - course_admin.course_id = course.course_id - db_session.add(course_admin) - db_session.commit() - - assert ( - db_session.query(CourseAdmin) - .filter_by(course_id=course.course_id) - .first() - .uid - == assistent.uid - ) + """Class to test the Course model""" + + def test_create_course(self, session: Session): + """Test if a course can be created""" + course = Course(name="SEL2", ufora_id="C003784A_2023", teacher="brinkmann") + session.add(course) + session.commit() + assert session.get(Course, course.course_id) is not None + assert session.query(Course).count() == 3 + + def test_query_course(self, session: Session): + """Test if a course can be queried""" + assert session.query(Course).count() == 2 + course = session.query(Course).filter_by(name="AD3").first() + assert course is not None + assert course.teacher == "brinkmann" + + def test_update_course(self, session: Session): + """Test if a course can be updated""" + course = session.query(Course).filter_by(name="AD3").first() + course.name = "AD2" + session.commit() + assert session.get(Course, course.course_id).name == "AD2" + + def test_delete_course(self, session: Session): + """Test if a course can be deleted""" + course = session.query(Course).first() + session.delete(course) + session.commit() + assert session.get(Course, course.course_id) is None + assert session.query(Course).count() == 1 + + def test_foreign_key_teacher(self, session: Session): + """Test the foreign key teacher""" + course = session.query(Course).filter_by(name="AD3").first() + course.teacher = "laermans" + session.commit() + assert session.get(Course, course.course_id).teacher == "laermans" + with raises(IntegrityError): + course.teacher = "unknown" + session.commit() + session.rollback() + + @mark.parametrize("property_name", ["name","teacher"]) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + course = session.query(Course).first() + with raises(IntegrityError): + setattr(course, property_name, None) + session.commit() + session.rollback() diff --git a/backend/tests/models/project_test.py b/backend/tests/models/project_test.py new file mode 100644 index 00000000..b99a6134 --- /dev/null +++ b/backend/tests/models/project_test.py @@ -0,0 +1,79 @@ +"""Project model tests""" + +from datetime import datetime +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.course import Course +from project.models.project import Project + +class TestProjectModel: + """Class to test the Project model""" + + def test_create_project(self, session: Session): + """Test if a project can be created""" + course = session.query(Course).first() + project = Project( + title="Pigeonhole", + description="A new project", + assignment_file="assignment.pdf", + deadline=datetime(2024,12,31,23,59,59), + course_id=course.course_id, + visible_for_students=True, + archived=False, + test_path="/test", + script_name="script", + regex_expressions=[r".*"] + ) + session.add(project) + session.commit() + assert project.project_id is not None + assert session.query(Project).count() == 3 + + def test_query_project(self, session: Session): + """Test if a project can be queried""" + assert session.query(Project).count() == 2 + project = session.query(Project).filter_by(title="Predicaten").first() + assert project is not None + assert project.course_id == session.query(Course).filter_by(name="RAF").first().course_id + + def test_update_project(self, session: Session): + """Test if a project can be updated""" + project = session.query(Project).filter_by(title="B+ Trees").first() + project.title = "Trees" + project.description = "Implement 3 trees of your choosing" + session.commit() + updated_project = session.get(Project, project.project_id) + assert updated_project.title == "Trees" + assert updated_project.description == "Implement 3 trees of your choosing" + + def test_delete_project(self, session: Session): + """Test if a project can be deleted""" + project = session.query(Project).first() + session.delete(project) + session.commit() + assert session.get(Project, project.project_id) is None + assert session.query(Project).count() == 1 + + def test_foreign_key_course_id(self, session: Session): + """Test the foreign key course_id""" + course = session.query(Course).filter_by(name="RAF").first() + project = session.query(Project).filter_by(title="B+ Trees").first() + project.course_id = course.course_id + session.commit() + assert project.course_id == course.course_id + with raises(IntegrityError): + project.course_id = 0 + session.commit() + session.rollback() + + @mark.parametrize("property_name", + ["title","description","course_id","visible_for_students","archived"] + ) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + project = session.query(Project).first() + with raises(IntegrityError): + setattr(project, property_name, None) + session.commit() + session.rollback() diff --git a/backend/tests/models/projects_and_submissions_test.py b/backend/tests/models/projects_and_submissions_test.py deleted file mode 100644 index 912d9294..00000000 --- a/backend/tests/models/projects_and_submissions_test.py +++ /dev/null @@ -1,63 +0,0 @@ -"""This module tests the Project and Submission model""" -from datetime import datetime -import pytest -from sqlalchemy.exc import IntegrityError -from project.models.project import Project -from project.models.submission import Submission - -class TestProjectAndSubmissionModel: # pylint: disable=too-few-public-methods - """Test class for the database models of projects and submissions""" - def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all arguments are needed for the test - course, - course_teacher, - valid_project, - valid_user): - """Tests if the deadline is correctly set - and if the submission is correctly connected to the project""" - db_session.add(course_teacher) - db_session.commit() - db_session.add(course) - db_session.commit() - valid_project.course_id = course.course_id - db_session.add(valid_project) - db_session.commit() - check_project = ( - db_session.query(Project).filter_by(title=valid_project.title).first() - ) - assert check_project.deadline == valid_project.deadline - - db_session.add(valid_user) - db_session.commit() - submission = Submission( - uid=valid_user.uid, - project_id=check_project.project_id, - submission_time=datetime.now(), - submission_path="/test/submission/", - submission_status=False, - ) - db_session.add(submission) - db_session.commit() - - submission_check = ( - db_session.query(Submission) - .filter_by(project_id=check_project.project_id) - .first() - ) - assert submission_check.uid == valid_user.uid - - with pytest.raises( - IntegrityError - ): - submission_check.grading = 100 - db_session.commit() - db_session.rollback() - submission_check.grading = 15 - db_session.commit() - submission_check = ( - db_session.query(Submission) - .filter_by(project_id=check_project.project_id) - .first() - ) - assert submission_check.grading == 15 - assert submission.grading == 15 - # Interesting! all the model objects are connected diff --git a/backend/tests/models/submission_test.py b/backend/tests/models/submission_test.py new file mode 100644 index 00000000..66a2779b --- /dev/null +++ b/backend/tests/models/submission_test.py @@ -0,0 +1,92 @@ +"""Submission model tests""" + +from datetime import datetime +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.project import Project +from project.models.submission import Submission + +class TestSubmissionModel: + """Class to test the Submission model""" + + def test_create_submission(self, session: Session): + """Test if a submission can be created""" + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = Submission( + uid="student01", + project_id=project.project_id, + submission_time=datetime(2023,3,15,13,0,0), + submission_path="/submissions", + submission_status=True + ) + session.add(submission) + session.commit() + assert submission.submission_id is not None + assert session.query(Submission).count() == 4 + + def test_query_submission(self, session: Session): + """Test if a submission can be queried""" + assert session.query(Submission).count() == 3 + submission = session.query(Submission).filter_by(uid="student01").first() + assert submission is not None + + def test_update_submission(self, session: Session): + """Test if a submission can be updated""" + submission = session.query(Submission).filter_by(uid="student01").first() + submission.uid = "student02" + submission.grading = 20 + session.commit() + updated_submission = session.get(Submission, submission.submission_id) + assert updated_submission.uid == "student02" + assert updated_submission.grading == 20 + + def test_delete_submission(self, session: Session): + """Test if a submission can be deleted""" + submission = session.query(Submission).first() + session.delete(submission) + session.commit() + assert session.get(Submission, submission.submission_id) is None + assert session.query(Submission).count() == 2 + + def test_foreign_key_uid(self, session: Session): + """Test the foreign key uid""" + submission = session.query(Submission).filter_by(uid="student01").first() + submission.uid = "student02" + session.commit() + assert submission.uid == "student02" + with raises(IntegrityError): + submission.uid = "unknown" + session.commit() + session.rollback() + + def test_foreign_key_project_id(self, session: Session): + """Test the foreign key project_id""" + submission = session.query(Submission).filter_by(uid="student01").first() + project = session.query(Project).filter_by(title="Predicaten").first() + submission.project_id = project.project_id + session.commit() + assert submission.project_id == project.project_id + with raises(IntegrityError): + submission.project_id = 0 + session.commit() + session.rollback() + + @mark.parametrize("property_name", + ["uid","project_id","submission_time","submission_path","submission_status"] + ) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + submission = session.query(Submission).first() + with raises(IntegrityError): + setattr(submission, property_name, None) + session.commit() + session.rollback() + + def test_grading_constraint(self, session: Session): + """Test if the grading is between 0 and 20""" + submission = session.query(Submission).first() + with raises(IntegrityError): + submission.grading = 80 + session.commit() + session.rollback() diff --git a/backend/tests/models/user_test.py b/backend/tests/models/user_test.py new file mode 100644 index 00000000..8a026711 --- /dev/null +++ b/backend/tests/models/user_test.py @@ -0,0 +1,57 @@ +"""User model tests""" + +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.user import User + +class TestUserModel: + """Class to test the User model""" + + def test_create_user(self, session: Session): + """Test if a user can be created""" + user = User(uid="user01", is_teacher=False, is_admin=False) + session.add(user) + session.commit() + assert session.get(User, "user01") is not None + assert session.query(User).count() == 5 + + def test_query_user(self, session: Session): + """Test if a user can be queried""" + assert session.query(User).count() == 4 + teacher = session.query(User).filter_by(uid="brinkmann").first() + assert teacher is not None + assert teacher.is_teacher + + def test_update_user(self, session: Session): + """Test if a user can be updated""" + student = session.query(User).filter_by(uid="student01").first() + student.is_admin = True + session.commit() + assert session.get(User, "student01").is_admin + + def test_delete_user(self, session: Session): + """Test if a user can be deleted""" + user = session.query(User).first() + session.delete(user) + session.commit() + assert session.get(User, user.uid) is None + assert session.query(User).count() == 3 + + @mark.parametrize("property_name", ["uid"]) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + user = session.query(User).first() + with raises(IntegrityError): + setattr(user, property_name, None) + session.commit() + session.rollback() + + @mark.parametrize("property_name", ["uid"]) + def test_property_unique(self, session: Session, property_name: str): + """Test if the property is unique""" + users = session.query(User).all() + with raises(IntegrityError): + setattr(users[0], property_name, getattr(users[1], property_name)) + session.commit() + session.rollback() diff --git a/backend/tests/models/users_test.py b/backend/tests/models/users_test.py deleted file mode 100644 index 2d77b953..00000000 --- a/backend/tests/models/users_test.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -This file contains the tests for the User model. -""" -from project.models.user import User - - -class TestUserModel: - """Test class for the database models""" - - def test_valid_user(self, db_session, valid_user): - """Tests if a valid user can be added to the database.""" - db_session.add(valid_user) - db_session.commit() - assert valid_user in db_session.query(User).all() - - def test_is_teacher(self, db_session, teachers): - """Tests if the is_teacher field is correctly set to True - for the teachers when added to the database.""" - db_session.add_all(teachers) - db_session.commit() - teacher_count = 0 - for usr in db_session.query(User).filter_by(is_teacher=True): - teacher_count += 1 - assert usr.is_teacher - assert teacher_count == 10 From b1687436f1c177ff9884a3b9c4906bd1753a7bdf Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:27:39 +0100 Subject: [PATCH 126/144] Tests/cleanup (#104) * command useless and takes time * cleaned up courses tests * cleaned up project tests * moved testzip to resources folder * cleaned up users tests * small cleanup submission tests * resolved linting * corrected function docs * re-using existing fixtures to reduce code duplication * resolved linter * removed useless fstring * removed unused import --- backend/Dockerfile.test | 3 - backend/tests/endpoints/conftest.py | 221 ++++++++++-------- .../tests/endpoints/course/courses_test.py | 193 +++------------ .../tests/endpoints/course/share_link_test.py | 17 +- backend/tests/endpoints/project_test.py | 81 ++----- backend/tests/endpoints/submissions_test.py | 80 +++---- backend/tests/endpoints/user_test.py | 84 ++++--- backend/{ => tests/resources}/testzip.zip | Bin 8 files changed, 258 insertions(+), 421 deletions(-) rename backend/{ => tests/resources}/testzip.zip (100%) diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test index f975133c..92ec8b63 100644 --- a/backend/Dockerfile.test +++ b/backend/Dockerfile.test @@ -10,6 +10,3 @@ COPY . /app RUN apt-get update RUN apt-get install -y --no-install-recommends python3-pip RUN pip3 install --no-cache-dir -r requirements.txt -r dev-requirements.txt - -# Command to run the tests -CMD ["pytest"] \ No newline at end of file diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index e87aed95..4466ee92 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,16 +3,87 @@ import tempfile import os from datetime import datetime +from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine -from project import create_app_with_db -from project.db_in import db, url from project.models.user import User from project.models.course import Course -from project.models.course_relation import CourseStudent,CourseAdmin from project.models.course_share_code import CourseShareCode +from project import create_app_with_db +from project.db_in import url, db +from project.models.submission import Submission from project.models.project import Project + +@pytest.fixture +def valid_submission(valid_user_entry, valid_project_entry): + """ + Returns a valid submission form + """ + return { + "uid": valid_user_entry.uid, + "project_id": valid_project_entry.project_id, + "grading": 16, + "submission_time": datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), + "submission_path": "/submission/1", + "submission_status": True + } + +@pytest.fixture +def valid_submission_entry(session, valid_submission): + """ + Returns a submission that is in the database + """ + submission = Submission(**valid_submission) + session.add(submission) + session.commit() + return submission + +@pytest.fixture +def valid_user(): + """ + Returns a valid user form + """ + return { + "uid": "w_student", + "is_teacher": False + } + +@pytest.fixture +def valid_user_entry(session, valid_user): + """ + Returns a user that is in the database + """ + user = User(**valid_user) + session.add(user) + session.commit() + return user + +@pytest.fixture +def user_invalid_field(valid_user): + """ + Returns a user form with an invalid field + """ + valid_user["is_student"] = True + return valid_user + +@pytest.fixture +def valid_user_entries(session): + """ + Returns a list of users that are in the database + """ + users = [ + User(uid="del", is_admin=False, is_teacher=True), + User(uid="pat", is_admin=False, is_teacher=True), + User(uid="u_get", is_admin=False, is_teacher=True), + User(uid="query_user", is_admin=True, is_teacher=False)] + + session.add_all(users) + session.commit() + + return users + + @pytest.fixture def file_empty(): """Return an empty file""" @@ -66,36 +137,28 @@ def course_ad(course_teacher_ad: User): return ad2 @pytest.fixture -def project(course): +def valid_project_entry(session, valid_project): """A project for testing, with the course as the course it belongs to""" - date = datetime(2024, 2, 25, 12, 0, 0) - project = Project( - title="Project", - description="Test project", - course_id=course.course_id, - deadline=date, - visible_for_students=True, - archived=False, - test_path="testpad", - script_name="testscript", - regex_expressions='r' - ) + project = Project(**valid_project) + + session.add(project) + session.commit() return project @pytest.fixture -def project_json(project: Project): - """A function that return the json data of a project including the PK needed for testing""" +def valid_project(valid_course_entry): + """A function that return the json form data of a project""" data = { - "title": project.title, - "description": project.description, - "assignment_file": project.assignment_file, - "deadline": project.deadline, - "course_id": project.course_id, - "visible_for_students": project.visible_for_students, - "archived": project.archived, - "test_path": project.test_path, - "script_name": project.script_name, - "regex_expressions": project.regex_expressions + "title": "Project", + "description": "Test project", + "assignment_file": "testfile", + "deadline": "2024-02-25T12:00:00", + "course_id": valid_course_entry.course_id, + "visible_for_students": True, + "archived": False, + "test_path": "tests", + "script_name": "script.sh", + "regex_expressions": ["*.pdf", "*.txt"] } return data @@ -112,88 +175,54 @@ def client(app): yield client @pytest.fixture -def courses_get_db(db_with_course): - """Database equipped for the get tests""" - for x in range(3,10): - course = Course(teacher="Bart", name="Sel" + str(x)) - db_with_course.add(course) - db_with_course.commit() - db_with_course.add(CourseAdmin(course_id=course.course_id,uid="Bart")) - db_with_course.commit() - course = db_with_course.query(Course).filter_by(name="Sel2").first() - db_with_course.add(CourseAdmin(course_id=course.course_id,uid="Rien")) - db_with_course.add_all( - [CourseStudent(course_id=course.course_id, uid="student_sel2_" + str(i)) - for i in range(3)]) - db_with_course.commit() - return db_with_course +def valid_teacher_entry(session): + """A valid teacher for testing that's already in the db""" + teacher = User(uid="Bart", is_teacher=True) + session.add(teacher) + session.commit() + return teacher @pytest.fixture -def db_with_course(courses_init_db): - """A database with a course.""" - courses_init_db.add(Course(name="Sel2", teacher="Bart")) - courses_init_db.commit() - course = courses_init_db.query(Course).filter_by(name="Sel2").first() - courses_init_db.add(CourseAdmin(course_id=course.course_id,uid="Bart")) - courses_init_db.commit() - return courses_init_db +def valid_course(valid_teacher_entry): + """A valid course json form""" + return {"name": "Sel", "teacher": valid_teacher_entry.uid} @pytest.fixture -def course_data(): - """A valid course for testing.""" - return {"name": "Sel2", "teacher": "Bart"} +def course_no_name(valid_teacher_entry): + """A course with no name""" + return {"name": "", "teacher": valid_teacher_entry.uid} @pytest.fixture -def invalid_course(): - """An invalid course for testing.""" - return {"invalid": "error"} +def valid_course_entry(session, valid_course): + """A valid course for testing that's already in the db""" + course = Course(**valid_course) + session.add(course) + session.commit() + return course @pytest.fixture -def courses_init_db(db_session, course_students, course_teacher, course_assistent): - """ - What do we need to test the courses api standalone: - A teacher that can make a new course - and some students - and an assistent - """ - db_session.add_all(course_students) - db_session.add(course_teacher) - db_session.add(course_assistent) - db_session.commit() - return db_session - -@pytest.fixture -def course_students(): - """A list of 5 students for testing.""" +def valid_students_entries(session): + """Valid students for testing that are already in the db""" students = [ - User(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) - for i in range(5) + User(uid=f"student_sel2_{i}", is_teacher=False) + for i in range(3) ] + session.add_all(students) + session.commit() return students @pytest.fixture -def course_teacher(): - """A user that's a teacher for testing""" - sel2_teacher = User(uid="Bart", is_teacher=True, is_admin=False) - return sel2_teacher - -@pytest.fixture -def course_assistent(): - """A user that's a teacher for testing""" - sel2_assistent = User(uid="Rien", is_teacher=True, is_admin=False) - return sel2_assistent - -@pytest.fixture -def course(course_teacher): - """A course for testing, with the course teacher as the teacher.""" - sel2 = Course(name="Sel2", teacher=course_teacher.uid) - return sel2 +def valid_course_entries(session, valid_teacher_entry): + """A valid course for testing that's already in the db""" + courses = [Course(name=f"Sel{i}", teacher=valid_teacher_entry.uid) for i in range(3)] + session.add_all(courses) + session.commit() + return courses @pytest.fixture -def share_code_admin(db_with_course): +def share_code_admin(session, valid_course_entry): """A course with share codes for testing.""" - course = db_with_course.query(Course).first() - share_code = CourseShareCode(course_id=course.course_id, for_admins=True) - db_with_course.add(share_code) - db_with_course.commit() + share_code = CourseShareCode(course_id=valid_course_entry.course_id, for_admins=True) + session.add(share_code) + session.commit() return share_code diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 5e9fde7d..c9b64e15 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,202 +1,73 @@ """Here we will test all the courses endpoint related functionality""" -from project.models.course_relation import CourseStudent, CourseAdmin - - -from project.models.course import Course - - class TestCourseEndpoint: """Class for testing the courses endpoint""" - def test_post_courses(self, courses_init_db, client, course_data, invalid_course): + def test_post_courses(self, client, valid_course): """ Test posting a course to the /courses endpoint """ - response = client.post("/courses?uid=Bart", json=course_data) # valid user - for x in range(3, 10): - course = {"name": "Sel" + str(x), "teacher": "Bart"} - response = client.post("/courses?uid=Bart", json=course) # valid user - assert response.status_code == 201 - assert response.status_code == 201 # succes post = 201 + response = client.post("/courses", json=valid_course) + assert response.status_code == 201 + data = response.json + assert data["data"]["name"] == "Sel" + assert data["data"]["teacher"] == valid_course["teacher"] - course = courses_init_db.query(Course).filter_by(name="Sel2").first() - assert course is not None - assert course.teacher == "Bart" + # Is reachable using the API + get_response = client.get(f"/courses/{data['data']['course_id']}") + assert get_response.status_code == 200 - response = client.post( - "/courses?uid=Bart", json=invalid_course - ) # invalid course - assert response.status_code == 400 - def test_post_courses_course_id_students_and_admins(self, db_with_course, client): + def test_post_courses_course_id_students_and_admins( + self, client, valid_course_entry, valid_students_entries): """ Test posting to courses/course_id/students and admins """ - course = db_with_course.query(Course).filter_by(name="Sel2").first() - # Posting to /courses/course_id/students and admins test - valid_students = { - "students": ["student_sel2_0", "student_sel2_1", "student_sel2_2"] - } - bad_students = {"error": ["student_sel2_0", "student_sel2_1"]} - sel2_students_link = "/courses/" + str(course.course_id) - response = client.post( - sel2_students_link + "/students?uid=student_sel2_0", - json=valid_students, # unauthorized user - ) - assert response.status_code == 403 - - assert course.teacher == "Bart" - response = client.post( - sel2_students_link + "/students?uid=Bart", - json=valid_students, # authorized user - ) - - assert response.status_code == 201 # succes post = 201 - users = [ - s.uid - for s in CourseStudent.query.filter_by(course_id=course.course_id).all() - ] - assert users == valid_students["students"] + # Posting to /courses/course_id/students and admins test + sel2_students_link = "/courses/" + str(valid_course_entry.course_id) - response = client.post( - sel2_students_link + "/students?uid=Bart", - json=valid_students, # already added students - ) - assert response.status_code == 400 + valid_students = [s.uid for s in valid_students_entries] response = client.post( - sel2_students_link + "/students?uid=Bart", - json=bad_students, # bad request + sel2_students_link + f"/students?uid={valid_course_entry.teacher}", + json={"students": valid_students}, ) - assert response.status_code == 400 - - sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - - course_admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert course_admins == ["Bart"] - response = client.post( - sel2_admins_link + "?uid=Bart", # authorized user - json={"admin_uid": "Rin"}, # non existent user - ) - assert response.status_code == 404 + assert response.status_code == 403 - response = client.post( - sel2_admins_link + "?uid=Bart", # authorized user - json={"admin_uid": "Rien"}, # existing user - ) - admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert admins == ["Bart", "Rien"] - def test_get_courses(self, courses_get_db, client, api_url): + def test_get_courses(self, valid_course_entries, client): """ Test all the getters for the courses endpoint """ - course = courses_get_db.query(Course).filter_by(name="Sel2").first() - sel2_students_link = "/courses/" + str(course.course_id) - - for x in range(3, 10): - response = client.get(f"/courses?name=Sel{str(x)}") - assert response.status_code == 200 - link = response.json["url"] - assert len(link) == len(f"{api_url}/courses") - response = client.get(link + "?uid=Bart") - assert response.status_code == 200 - - sel2_students = [ - {"uid": f"{api_url}/users/" + s.uid} - for s in CourseStudent.query.filter_by(course_id=course.course_id).all() - ] - - response = client.get(sel2_students_link + "/students?uid=Bart") + + response = client.get("/courses") assert response.status_code == 200 - response_json = response.json # the students ids are in the json without a key - assert response_json["data"] == sel2_students + data = response.json + for course in valid_course_entries: + assert course.name in [c["name"] for c in data["data"]] - def test_course_delete(self, courses_get_db, client): + def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" - course = courses_get_db.query(Course).filter_by(name="Sel2").first() - sel2_students_link = "/courses/" + str(course.course_id) - response = client.delete( - sel2_students_link + "/students?uid=student_sel2_0", - json={"students": ["student_sel2_0"]}, - ) - assert response.status_code == 403 - response = client.delete( - sel2_students_link + "/students?uid=Bart", - json={"students": ["student_sel2_0"]}, + "/courses/" + str(valid_course_entry.course_id), ) assert response.status_code == 200 - students = [ - s.uid - for s in CourseStudent.query.filter_by(course_id=course.course_id).all() - ] - assert students == ["student_sel2_1", "student_sel2_2"] - - response = client.delete( - sel2_students_link + "/students?uid=Bart", json={"error": ["invalid"]} - ) - assert response.status_code == 400 - - response = client.delete( - sel2_students_link + "/admins?uid=Bart", json={"admin_uid": "error"} - ) - assert response.status_code == 404 - - assert ( - sel2_students_link + "/admins?uid=Bart" - == "/courses/" + str(course.course_id) + "/admins?uid=Bart" - ) - response = client.delete( - sel2_students_link + "/admins?uid=Bart", json={"admin_ud": "Rien"} - ) - assert response.status_code == 400 - - response = client.delete( - sel2_students_link + "/admins?uid=student_sel2_0", - json={"admin_uid": "Rien"}, - ) - assert response.status_code == 403 - - admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert admins == ["Bart", "Rien"] - response = client.delete( - sel2_students_link + "/admins?uid=Bart", json={"admin_uid": "Rien"} - ) - assert response.status_code == 204 - - admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert admins == ["Bart"] - - course = Course.query.filter_by(name="Sel2").first() - assert course.teacher == "Bart" + # Is not reachable using the API + get_response = client.get(f"/courses/{valid_course_entry.course_id}") + assert get_response.status_code == 404 - def test_course_patch(self, client, session): + def test_course_patch(self, valid_course_entry, client): """ Test the patching of a course """ - course = session.query(Course).filter_by(name="AD3").first() - response = client.patch(f"/courses/{course.course_id}?uid=brinkmann", json={ - "name": "AD2" + response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ + "name": "TestTest" }) data = response.json assert response.status_code == 200 - assert data["data"]["name"] == "AD2" + assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 85f3346e..6ca89968 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -2,8 +2,6 @@ This file contains the tests for the share link endpoints of the course resource. """ -from project.models.course import Course - class TestCourseShareLinks: """ Class that will respond to the /courses/course_id/students link @@ -11,17 +9,15 @@ class TestCourseShareLinks: and everyone should be able to list all students assigned to a course """ - def test_get_share_links(self, db_with_course, client): + def test_get_share_links(self, valid_course_entry, client): """Test whether the share links are accessible""" - example_course = db_with_course.query(Course).first() - response = client.get(f"courses/{example_course.course_id}/join_codes") + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes") assert response.status_code == 200 - def test_post_share_links(self, db_with_course, client): + def test_post_share_links(self, valid_course_entry, client): """Test whether the share links are accessible to post to""" - example_course = db_with_course.query(Course).first() response = client.post( - f"courses/{example_course.course_id}/join_codes", + f"courses/{valid_course_entry.course_id}/join_codes", json={"for_admins": True}) assert response.status_code == 201 @@ -46,8 +42,7 @@ def test_delete_share_links_404(self, client): response = client.delete("courses/0/join_codes/0") assert response.status_code == 404 - def test_for_admins_required(self, db_with_course, client): + def test_for_admins_required(self, valid_course_entry, client): """Test whether the for_admins field is required""" - example_course = db_with_course.query(Course).first() - response = client.post(f"courses/{example_course.course_id}/join_codes", json={}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index f8949ff6..24fcc2d0 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,26 +1,19 @@ """Tests for project endpoints.""" -from project.models.project import Project -def test_assignment_download(db_session, client, course_ad, course_teacher_ad, project_json): +def test_assignment_download(client, valid_project): """ Method for assignment download """ - db_session.add(course_teacher_ad) - db_session.commit() - db_session.add(course_ad) - db_session.commit() - project_json["course_id"] = course_ad.course_id - - with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = zip_file + with open("tests/resources/testzip.zip", "rb") as zip_file: + valid_project["assignment_file"] = zip_file # post the project response = client.post( "/projects", - data=project_json, + data=valid_project, content_type='multipart/form-data' ) - + assert response.status_code == 201 project_id = response.json["data"]["project_id"] response = client.get(f"/projects/{project_id}/assignments") # file downloaded succesfully @@ -33,8 +26,7 @@ def test_not_found_download(client): """ response = client.get("/projects") # get an index that doesnt exist - project_id = len(response.data)+1 - response = client.get(f"/projects/{project_id}/assignments") + response = client.get("/projects/-1/assignments") assert response.status_code == 404 @@ -51,23 +43,15 @@ def test_getting_all_projects(client): assert isinstance(response.json['data'], list) -def test_post_project(db_session, client, course_ad, course_teacher_ad, project_json): +def test_post_project(client, valid_project): """Test posting a project to the database and testing if it's present""" - db_session.add(course_teacher_ad) - db_session.commit() - - db_session.add(course_ad) - db_session.commit() - project_json["course_id"] = course_ad.course_id - # cant be done with 'with' because it autocloses then - # pylint: disable=R1732 - with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = zip_file + with open("tests/resources/testzip.zip", "rb") as zip_file: + valid_project["assignment_file"] = zip_file # post the project response = client.post( "/projects", - data=project_json, + data=valid_project, content_type='multipart/form-data' ) @@ -79,60 +63,29 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ assert response.status_code == 200 -def test_remove_project(db_session, client, course_ad, course_teacher_ad, project_json): +def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" - db_session.add(course_teacher_ad) - db_session.commit() - - db_session.add(course_ad) - db_session.commit() - - project_json["course_id"] = course_ad.course_id - - # post the project - print(project_json) - with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = zip_file - response = client.post("/projects", data=project_json) - - # check if the project with the id is present - project_id = response.json["data"]["project_id"] - + project_id = valid_project_entry.project_id response = client.delete(f"/projects/{project_id}") assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.delete(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}") assert response.status_code == 404 -def test_patch_project(db_session, client, course_ad, course_teacher_ad, project): +def test_patch_project(client, valid_project_entry): """ Test functionality of the PUT method for projects """ - db_session.add(course_teacher_ad) - db_session.commit() - - db_session.add(course_ad) - db_session.commit() - - project.course_id = course_ad.course_id - - # post the project to edit - db_session.add(project) - db_session.commit() - project_id = project.project_id + project_id = valid_project_entry.project_id - new_title = "patched title" - new_archived = not project.archived + new_title = valid_project_entry.title + "hallo" + new_archived = not valid_project_entry.archived response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived }) - db_session.commit() - updated_project = db_session.get(Project, {"project_id": project.project_id}) assert response.status_code == 200 - assert updated_project.title == new_title - assert updated_project.archived == new_archived diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 7736bf90..be36592f 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -12,99 +12,92 @@ class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session): + def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=unknown") - data = response.json + response = client.get("/submissions?uid=-20") assert response.status_code == 400 - assert data["message"] == "Invalid user (uid=unknown)" - def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): + def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") - data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=-1)" + assert "message" in response.json - def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" response = client.get("/submissions?project_id=zero") - data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=zero)" + assert "message" in response.json - def test_get_submissions_all(self, client: FlaskClient, session: Session): + def test_get_submissions_all(self, client: FlaskClient): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 3 + assert "message" in data + assert isinstance(data["data"], list) - def test_get_submissions_user(self, client: FlaskClient, session: Session): + def test_get_submissions_user(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific user""" - response = client.get("/submissions?uid=student01") + response = client.get(f"/submissions?uid={valid_submission_entry.uid}") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 1 + assert "message" in data - def test_get_submissions_project(self, client: FlaskClient, session: Session): + + def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.get(f"/submissions?project_id={project.project_id}") + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 2 + assert "message" in data - def test_get_submissions_user_project(self, client: FlaskClient, session: Session): + def test_get_submissions_user_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific user and project""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.get(f"/submissions?uid=student01&project_id={project.project_id}") + response = client.get( + f"/submissions? \ + uid={valid_submission_entry.uid}&\ + project_id={valid_submission_entry.project_id}") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 1 + assert "message" in data ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, session: Session, files): + def test_post_submissions_no_user(self, client: FlaskClient, valid_project_entry, files): """Test posting a submission without specifying a user""" - project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ - "project_id": project.project_id, + "project_id": valid_project_entry.project_id, "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The uid is missing" - def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): + def test_post_submissions_wrong_user(self, client: FlaskClient, valid_project_entry, files): """Test posting a submission for a non-existing user""" - project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "unknown", - "project_id": project.project_id, + "project_id": valid_project_entry.project_id, "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid user (uid=unknown)" - def test_post_submissions_no_project(self, client: FlaskClient, session: Session, files): + def test_post_submissions_no_project(self, client: FlaskClient, valid_user_entry, files): """Test posting a submission without specifying a project""" response = client.post("/submissions", data={ - "uid": "student01", + "uid": valid_user_entry.uid, "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The project_id is missing" - def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session, files): + def test_post_submissions_wrong_project(self, client: FlaskClient, valid_user_entry, files): """Test posting a submission for a non-existing project""" response = client.post("/submissions", data={ - "uid": "student01", + "uid": valid_user_entry.uid, "project_id": 0, "files": files }) @@ -113,11 +106,11 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess assert data["message"] == "Invalid project (project_id=0)" def test_post_submissions_wrong_project_type( - self, client: FlaskClient, session: Session, files + self, client: FlaskClient, valid_user_entry, files ): """Test posting a submission for a non-existing project of the wrong type""" response = client.post("/submissions", data={ - "uid": "student01", + "uid": valid_user_entry.uid, "project_id": "zero", "files": files }) @@ -125,17 +118,18 @@ def test_post_submissions_wrong_project_type( assert response.status_code == 400 assert data["message"] == "Invalid project_id typing (project_id=zero)" - def test_post_submissions_no_files(self, client: FlaskClient, session: Session): + def test_post_submissions_no_files( + self, client: FlaskClient, valid_user_entry, valid_project_entry): """Test posting a submission when no files are uploaded""" - project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id + "uid": valid_user_entry.uid, + "project_id": valid_project_entry.project_id }) data = response.json assert response.status_code == 400 assert data["message"] == "No files were uploaded" + def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): """Test posting a submission for an empty file""" project = session.query(Project).filter_by(title="B+ Trees").first() diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 96b13e3c..c20b0a29 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -7,6 +7,7 @@ - test_patch_user: Tests user update functionality and error handling for updating non-existent user. """ +from dataclasses import asdict import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine @@ -39,80 +40,78 @@ def user_db_session(): class TestUserEndpoint: """Class to test user management endpoints.""" - def test_delete_user(self, client,user_db_session): + def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete("/users/del") + response = client.delete(f"/users/{valid_user_entry.uid}") assert response.status_code == 200 - assert response.json["message"] == "User deleted successfully!" - def test_delete_not_present(self, client,user_db_session): + get_response = client.get(f"/users/{valid_user_entry.uid}") + assert get_response.status_code == 404 + + def test_delete_not_present(self, client): """Test deleting a user that does not exist.""" - response = client.delete("/users/non") + response = client.delete("/users/-20") assert response.status_code == 404 - def test_wrong_form_post(self, client,user_db_session): + def test_wrong_form_post(self, client, user_invalid_field): """Test posting with a wrong form.""" - response = client.post("/users", json={ - 'uid': '12', - 'is_student': True, # wrong field name - 'is_admin': False - }) + response = client.post("/users", json=user_invalid_field) assert response.status_code == 400 - def test_wrong_datatype_post(self, client,user_db_session): - """Test posting with a wrong data type.""" - response = client.post("/users", data={ - 'uid': '12', - 'is_teacher': True, - 'is_admin': False - }) + def test_wrong_datatype_post(self, client, valid_user): + """Test posting with a wrong content type.""" + response = client.post("/users", data=valid_user) assert response.status_code == 415 - def test_get_all_users(self, client,user_db_session): + def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" response = client.get("/users") assert response.status_code == 200 # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) + for valid_user in valid_user_entries: + assert valid_user.uid in \ + [user["uid"] for user in response.json["data"]] - def test_get_one_user(self, client,user_db_session): + def test_get_one_user(self, client, valid_user_entry): """Test getting a single user.""" - response = client.get("users/u_get") + response = client.get(f"users/{valid_user_entry.uid}") assert response.status_code == 200 - assert response.json["data"] == { - 'uid': 'u_get', - 'is_teacher': True, - 'is_admin': False - } + assert "data" in response.json - def test_patch_user(self, client, user_db_session): + def test_patch_user(self, client, valid_user_entry): """Test updating a user.""" - response = client.patch("/users/pat", json={ - 'is_teacher': False, - 'is_admin': True + + new_is_teacher = not valid_user_entry.is_teacher + + response = client.patch(f"/users/{valid_user_entry.uid}", json={ + 'is_teacher': new_is_teacher, + 'is_admin': not valid_user_entry.is_admin }) assert response.status_code == 200 assert response.json["message"] == "User updated successfully!" - def test_patch_non_existent(self, client,user_db_session): + get_response = client.get(f"/users/{valid_user_entry.uid}") + assert get_response.status_code == 200 + assert get_response.json["data"]["is_teacher"] == new_is_teacher + + def test_patch_non_existent(self, client): """Test updating a non-existent user.""" - response = client.patch("/users/non", json={ + response = client.patch("/users/-20", json={ 'is_teacher': False, 'is_admin': True }) assert response.status_code == 404 - def test_patch_non_json(self, client,user_db_session): + def test_patch_non_json(self, client, valid_user_entry): """Test sending a non-JSON patch request.""" - response = client.post("/users", data={ - 'uid': '12', - 'is_teacher': True, - 'is_admin': False - }) + valid_user_form = asdict(valid_user_entry) + valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) assert response.status_code == 415 - def test_get_users_with_query(self, client, user_db_session): + def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing response = client.get("/users?is_admin=true&is_teacher=false") @@ -120,7 +119,6 @@ def test_get_users_with_query(self, client, user_db_session): # Check that the response contains only the user that matches the query users = response.json["data"] - assert len(users) == 1 - assert users[0]["uid"] == "query_user" - assert users[0]["is_admin"] is True - assert users[0]["is_teacher"] is False + for user in users: + assert user["is_admin"] is True + assert user["is_teacher"] is False diff --git a/backend/testzip.zip b/backend/tests/resources/testzip.zip similarity index 100% rename from backend/testzip.zip rename to backend/tests/resources/testzip.zip From ba044fc9148a1855282df3dc22a304fe2396f040 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 14 Mar 2024 20:48:08 +0100 Subject: [PATCH 127/144] fix badge for workflow in readme.md (#108) * test example workflow * check if push argument works * does the value update test * Create ci-test-backend.yaml created a seperate file for the backend test * added backend badge to readme * changed yml to yaml * changed tests to test * added seperate files for linter and tests for both front and backend * gave correct names to workflows * correct names to run name * removed code dupe and pray for test run * added both badges for linter and tests from backend * file changes detected? * removed unused code parts * fix: eslint not found error * added frontend badges --- .github/workflows/ci-linter-backend.yaml | 19 +++++++ .github/workflows/ci-linter-frontend.yaml | 27 ++++++++++ .github/workflows/ci-test-backend.yaml | 24 +++++++++ .github/workflows/ci-test-frontend.yaml | 35 +++++++++++++ .github/workflows/ci-tests.yml | 62 ----------------------- backend/README.md | 2 + frontend/README.md | 4 ++ 7 files changed, 111 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/ci-linter-backend.yaml create mode 100644 .github/workflows/ci-linter-frontend.yaml create mode 100644 .github/workflows/ci-test-backend.yaml create mode 100644 .github/workflows/ci-test-frontend.yaml delete mode 100644 .github/workflows/ci-tests.yml diff --git a/.github/workflows/ci-linter-backend.yaml b/.github/workflows/ci-linter-backend.yaml new file mode 100644 index 00000000..d9bb1150 --- /dev/null +++ b/.github/workflows/ci-linter-backend.yaml @@ -0,0 +1,19 @@ +name: UGent-3-backend-linter +run-name: ${{ github.actor }} is running backend linter 🚀 +on: [pull_request] +jobs: + Backend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + cache: 'pip' + + - name: Run linting + working-directory: ./backend + run: find . -type f -name "*.py" | xargs pylint + \ No newline at end of file diff --git a/.github/workflows/ci-linter-frontend.yaml b/.github/workflows/ci-linter-frontend.yaml new file mode 100644 index 00000000..f93d6325 --- /dev/null +++ b/.github/workflows/ci-linter-frontend.yaml @@ -0,0 +1,27 @@ +name: UGent-3-frontend-linter +run-name: ${{ github.actor }} is running frontend linter 🚀 +on: [pull_request] +jobs: + Frontend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Install dependencies + working-directory: ./frontend + run: npm i eslint + + - name: Run linting + working-directory: ./frontend + run: npm run lint + \ No newline at end of file diff --git a/.github/workflows/ci-test-backend.yaml b/.github/workflows/ci-test-backend.yaml new file mode 100644 index 00000000..5335aa08 --- /dev/null +++ b/.github/workflows/ci-test-backend.yaml @@ -0,0 +1,24 @@ +name: UGent-3-backend-test +run-name: ${{ github.actor }} is running backend tests 🚀 +on: [pull_request] +jobs: + Backend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + cache: 'pip' + + - name: Install dependencies + working-directory: ./backend + run: pip3 install -r requirements.txt && pip3 install -r dev-requirements.txt + + - name: Running tests + working-directory: ./backend + run: bash ./run_tests.sh + + diff --git a/.github/workflows/ci-test-frontend.yaml b/.github/workflows/ci-test-frontend.yaml new file mode 100644 index 00000000..8d976eb4 --- /dev/null +++ b/.github/workflows/ci-test-frontend.yaml @@ -0,0 +1,35 @@ +name: UGent-3-frontend-test +run-name: ${{ github.actor }} is running frontend tests 🚀 +on: [pull_request] +jobs: + Frontend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Build + working-directory: ./frontend + run: npm run build + + - name: Preview Web App + working-directory: ./frontend + run: npm run preview & + + - name: Running tests + working-directory: ./frontend + run: npm test + diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml deleted file mode 100644 index bd101643..00000000 --- a/.github/workflows/ci-tests.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: UGent-3 -run-name: ${{ github.actor }} is running tests 🚀 -on: [pull_request] -jobs: - Frontend-tests: - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ hashFiles('package-lock.json') }} - restore-keys: npm- - - - name: Install dependencies - working-directory: ./frontend - run: npm ci - - - name: Build - working-directory: ./frontend - run: npm run build - - - name: Preview Web App - working-directory: ./frontend - run: npm run preview & - - - name: Running tests - working-directory: ./frontend - run: npm test - - - name: Run linting - working-directory: ./frontend - run: npm run lint - Backend-tests: - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - cache: 'pip' - - - name: Install dependencies - working-directory: ./backend - run: pip3 install -r requirements.txt && pip3 install -r dev-requirements.txt - - - name: Running tests - working-directory: ./backend - run: bash ./run_tests.sh - - - name: Run linting - working-directory: ./backend - run: find . -type f -name "*.py" | xargs pylint - - diff --git a/backend/README.md b/backend/README.md index 8beb249d..b7cd5ee4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,6 @@ # Project pigeonhole backend +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg) ## Prerequisites **1. Clone the repo** ```sh diff --git a/frontend/README.md b/frontend/README.md index 0d6babed..be6865ac 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,7 @@ +# Project pigeonhole backend +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. From 1af5c7ec7c680c6fdd9573ad1d352ebe3f85e263 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 14 Mar 2024 21:55:50 +0100 Subject: [PATCH 128/144] frontend developper instructions (#110) * frontend developper instructions * only 1 backtick fix --- frontend/README.md | 80 +++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index be6865ac..5f81d16b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,34 +1,54 @@ # Project pigeonhole backend ![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) ![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) +## Prerequisites +**1. Clone the repo** + ```sh + git clone git@github.com:SELab-2/UGent-3.git + ``` + +**2. Installing required packages** +Run the command below to install the needed dependencies. + ```sh + cd frontend + npm install + ``` +After this you can run the development or the production build with one of the following command + - Deployment + ```sh + npm run build + ``` +After running this command the build can be found in the `dist` directory. +You can choose your own preferred webserver like for example `nginx`, `serve` or something else. + + - Development + ```sh + npm run dev + ``` + +## Maintaining the codebase +### Writing tests +When writing new code it is important to maintain the right functionality so +writing tests is mandatory for this, the test library used in this codebase is [cypres e2e](https://www.cypress.io/). + +If you want to write tests we highly advise to read the cypres e2e documentation on how +to write tests, so they are kept conventional. + +For executing the tests and testing your newly added functionality +you can run: +```sh +npm run dev +``` +After the development build is running, you can run the following command on another terminal: +```sh +npm run test +``` +### Running the linter +This codebase is kept clean by the [eslint](https://eslint.org) linter. + +If you want to execute the linter on all files in the project it can simply be done +with the command: +```sh +npm run lint +``` -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} -``` - -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list From 7c0cc085578389aeecbad3bbc149ad274938aae7 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 14 Mar 2024 21:57:17 +0100 Subject: [PATCH 129/144] Merge backend authentication into development (#73) * start authentication * authentication start decorators * login_required should work with access token * backend authentication for most endpoints (very rough draft of functions in authentication.py) * clean_up_function * authentication cleanup * give error when access_token fails * documentation auth functions * fixed imports * actual import fix * added requests * authorize submissions * removed double checks * start testing setup backend authentication * poging testen * github tests check * user tests with authentication * auth url accessible hopefully * change authorization to be easier to deal with since it doesn't matter for tests * fixed jobCategory -> jobTitle * fix authentication * user tests zouden moeten slagen * fix authentication arguments * project tests with authentication * changed auth server id of teacher * maybe correct primary keys * second try on primary key of course relations * further test authentication * authentication on project assignment files * auth on course_join_codes and extra tests * teacher_id in function when necessary * user tests with authentication * extra testing * fixed comments * lots of testing changes * should be 1 error test now * fix tests --- backend/Dockerfile_auth_test | 9 + backend/auth_requirements.txt | 4 + .../courses/course_admin_relation.py | 6 +- .../endpoints/courses/course_details.py | 4 + .../courses/course_student_relation.py | 8 +- backend/project/endpoints/courses/courses.py | 10 +- .../courses/join_codes/course_join_code.py | 6 +- .../courses/join_codes/course_join_codes.py | 5 +- .../courses/join_codes/join_codes_utils.py | 6 +- .../projects/project_assignment_file.py | 3 + .../endpoints/projects/project_detail.py | 5 +- .../project/endpoints/projects/projects.py | 8 +- backend/project/endpoints/submissions.py | 8 +- backend/project/endpoints/users.py | 11 +- backend/project/utils/authentication.py | 395 ++++++++++++++++++ backend/project/utils/query_agent.py | 2 +- backend/requirements.txt | 3 +- backend/test_auth_server/__main__.py | 69 +++ backend/tests.yaml | 13 + backend/tests/conftest.py | 2 +- .../tests/endpoints/course/courses_test.py | 14 +- .../tests/endpoints/course/share_link_test.py | 17 +- backend/tests/endpoints/project_test.py | 23 +- backend/tests/endpoints/submissions_test.py | 186 +-------- backend/tests/endpoints/user_test.py | 73 +++- 25 files changed, 650 insertions(+), 240 deletions(-) create mode 100644 backend/Dockerfile_auth_test create mode 100644 backend/auth_requirements.txt create mode 100644 backend/project/utils/authentication.py create mode 100644 backend/test_auth_server/__main__.py diff --git a/backend/Dockerfile_auth_test b/backend/Dockerfile_auth_test new file mode 100644 index 00000000..d7541b0d --- /dev/null +++ b/backend/Dockerfile_auth_test @@ -0,0 +1,9 @@ +FROM python:3.9 +RUN mkdir /auth-app +WORKDIR /auth-app +ADD ./test_auth_server /auth-app/ +COPY auth_requirements.txt /auth-app/requirements.txt +RUN pip3 install -r requirements.txt +COPY . /auth-app +ENTRYPOINT ["python"] +CMD ["__main__.py"] \ No newline at end of file diff --git a/backend/auth_requirements.txt b/backend/auth_requirements.txt new file mode 100644 index 00000000..2a0efdf3 --- /dev/null +++ b/backend/auth_requirements.txt @@ -0,0 +1,4 @@ +flask~=3.0.2 +flask-restful +python-dotenv~=1.0.1 +psycopg2-binary \ No newline at end of file diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index cb00cb51..bd8e1fa6 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import request +from flask import abort, request from flask_restful import Resource from project.models.course_relation import CourseAdmin @@ -21,6 +21,7 @@ json_message ) from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import login_required, authorize_teacher_of_course, authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") @@ -32,6 +33,7 @@ class CourseForAdmins(Resource): the /courses/course_id/admins url, only the teacher of a course can do this """ + @authorize_teacher_or_course_admin def get(self, course_id): """ This function will return all the admins of a course @@ -47,6 +49,7 @@ def get(self, course_id): filters={"course_id": course_id}, ) + @authorize_teacher_of_course def post(self, course_id): """ Api endpoint for adding new admins to a course, can only be done by the teacher @@ -72,6 +75,7 @@ def post(self, course_id): "uid" ) + @authorize_teacher_of_course def delete(self, course_id): """ Api endpoint for removing admins of a course, can only be done by the teacher diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 41b4abd5..56751c3d 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -19,6 +19,7 @@ from project.db_in import db from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model +from project.utils.authentication import login_required, authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -27,6 +28,7 @@ class CourseByCourseId(Resource): """Api endpoint for the /courses/course_id link""" + @login_required def get(self, course_id): """ This get function will return all the related projects of the course @@ -86,6 +88,7 @@ def get(self, course_id): "error": "Something went wrong while querying the database.", "url": RESPONSE_URL}, 500 + @authorize_teacher_of_course def delete(self, course_id): """ This function will delete the course with course_id @@ -97,6 +100,7 @@ def delete(self, course_id): RESPONSE_URL ) + @authorize_teacher_of_course def patch(self, course_id): """ This function will update the course with course_id diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 63b9213d..31e9c28c 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -26,6 +26,7 @@ ) from project.utils.query_agent import query_selected_from_model +from project.utils.authentication import login_required, authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") @@ -38,13 +39,14 @@ class CourseToAddStudents(Resource): and everyone should be able to list all students assigned to a course """ + @login_required def get(self, course_id): """ Get function at /courses/course_id/students to get all the users assigned to a course everyone can get this data so no need to have uid query in the link """ - abort_url = f"{API_URL}/courses/{str(course_id)}/students" + abort_url = f"{API_URL}/courses/{course_id}/students" get_course_abort_if_not_found(course_id) return query_selected_from_model( @@ -55,12 +57,13 @@ def get(self, course_id): filters={"course_id": course_id} ) + @authorize_teacher_or_course_admin def post(self, course_id): """ Allows admins of a course to assign new students by posting to: /courses/course_id/students with a list of uid in the request body under key "students" """ - abort_url = f"{API_URL}/courses/{str(course_id)}/students" + abort_url = f"{API_URL}/courses/{course_id}/students" uid = request.args.get("uid") data = request.get_json() student_uids = data.get("students") @@ -85,6 +88,7 @@ def post(self, course_id): response["data"] = data return response, 201 + @authorize_teacher_or_course_admin def delete(self, course_id): """ This function allows admins of a course to remove students by sending a delete request to diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index bafd881e..a56541e7 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -14,6 +14,7 @@ from project.models.course import Course from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import login_required, authorize_teacher load_dotenv() API_URL = getenv("API_HOST") @@ -22,6 +23,7 @@ class CourseForUser(Resource): """Api endpoint for the /courses link""" + @login_required def get(self): """ " Get function for /courses this will be the main endpoint @@ -36,15 +38,17 @@ def get(self): filters=request.args ) - def post(self): + @authorize_teacher + def post(self, teacher_id=None): """ This function will create a new course if the body of the post contains a name and uid is an admin or teacher """ - + req = request.json + req["teacher"] = teacher_id return insert_into_model( Course, - request.json, + req, RESPONSE_URL, "course_id", required_fields=["name", "teacher"] diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py index df952877..97f7284d 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_code.py +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -10,6 +10,7 @@ from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists +from project.utils.authentication import authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -18,7 +19,7 @@ class CourseJoinCode(Resource): """ This class will handle post and delete queries to - the /courses/course_id/join_codes url, only an admin of a course can do this + the /courses/course_id/join_codes/ url, only an admin of a course can do this """ @check_course_exists @@ -35,9 +36,10 @@ def get(self, course_id, join_code): ) @check_course_exists + @authorize_teacher_of_course def delete(self, course_id, join_code): """ - Api endpoint for adding new join codes to a course, can only be done by the teacher + Api endpoint for deleting join codes from a course, can only be done by the teacher """ return delete_by_id_from_model( diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index 7ab142b6..103de7db 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -11,6 +11,7 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.courses_utils import get_course_abort_if_not_found +from project.utils.authentication import login_required, authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -18,10 +19,11 @@ class CourseJoinCodes(Resource): """ - This class will handle post and delete queries to + This class will handle get and post queries to the /courses/course_id/join_codes url, only an admin of a course can do this """ + @login_required def get(self, course_id): """ This function will return all the join codes of a course @@ -36,6 +38,7 @@ def get(self, course_id): filters={"course_id": course_id} ) + @authorize_teacher_of_course def post(self, course_id): """ Api endpoint for adding new join codes to a course, can only be done by the teacher diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py index 5078fce2..65defbb4 100644 --- a/backend/project/endpoints/courses/join_codes/join_codes_utils.py +++ b/backend/project/endpoints/courses/join_codes/join_codes_utils.py @@ -8,7 +8,7 @@ def check_course_exists(func): """ Middleware to check if the course exists before handling the request """ - def wrapper(self, course_id, join_code, *args, **kwargs): - get_course_abort_if_not_found(course_id) - return func(self, course_id, join_code, *args, **kwargs) + def wrapper(*args, **kwargs): + get_course_abort_if_not_found(kwargs["course_id"]) + return func(*args, **kwargs) return wrapper diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 61447c94..88e12ac7 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -12,6 +12,7 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model +from project.utils.authentication import authorize_project_visible API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -21,6 +22,8 @@ class ProjectAssignmentFiles(Resource): """ Class for getting the assignment files of a project """ + + @authorize_project_visible def get(self, project_id): """ Get the assignment files of a project diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index df4e99d7..691aacf0 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -12,7 +12,7 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model - +from project.utils.authentication import authorize_teacher_or_project_admin, authorize_teacher_of_project, authorize_project_visible API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -24,6 +24,7 @@ class ProjectDetail(Resource): for implementing get, delete and put methods """ + @authorize_project_visible def get(self, project_id): """ Get method for listing a specific project @@ -37,6 +38,7 @@ def get(self, project_id): project_id, RESPONSE_URL) + @authorize_teacher_or_project_admin def patch(self, project_id): """ Update method for updating a specific project @@ -51,6 +53,7 @@ def patch(self, project_id): request.json ) + @authorize_teacher_of_project def delete(self, project_id): """ Delete a project and all of its submissions in cascade diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index eabd29f9..ccbdca70 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -9,9 +9,9 @@ from flask import request, jsonify from flask_restful import Resource - from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance +from project.utils.authentication import authorize_teacher from project.endpoints.projects.endpoint_parser import parse_project_params @@ -25,7 +25,8 @@ class ProjectsEndpoint(Resource): for implementing get method """ - def get(self): + @authorize_teacher + def get(self, teacher_id=None): """ Get method for listing all available projects that are currently in the API @@ -39,7 +40,8 @@ def get(self): filters=request.args ) - def post(self): + @authorize_teacher + def post(self, teacher_id=None): """ Post functionality for project using flask_restfull parse lib diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 62d289ae..34ae2282 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -14,6 +14,7 @@ from project.utils.files import filter_files, all_files_uploaded, zip_files from project.utils.user import is_valid_user from project.utils.project import is_valid_project +from project.utils.authentication import authorize_submission_request, authorize_submissions_request, authorize_grader, authorize_student_submission, authorize_submission_author load_dotenv() API_HOST = getenv("API_HOST") @@ -24,6 +25,7 @@ class SubmissionsEndpoint(Resource): """API endpoint for the submissions""" + @authorize_submissions_request def get(self) -> dict[str, any]: """Get all the submissions from a user for a project @@ -66,6 +68,7 @@ def get(self) -> dict[str, any]: data["message"] = "An error occurred while fetching the submissions" return data, 500 + @authorize_student_submission def post(self) -> dict[str, any]: """Post a new submission to a project @@ -142,6 +145,7 @@ def post(self) -> dict[str, any]: class SubmissionEndpoint(Resource): """API endpoint for the submission""" + @authorize_submission_request def get(self, submission_id: int) -> dict[str, any]: """Get the submission given an submission ID @@ -180,6 +184,7 @@ def get(self, submission_id: int) -> dict[str, any]: f"An error occurred while fetching the submission (submission_id={submission_id})" return data, 500 + @authorize_grader def patch(self, submission_id:int) -> dict[str, any]: """Update some fields of a submission given a submission ID @@ -232,6 +237,7 @@ def patch(self, submission_id:int) -> dict[str, any]: f"An error occurred while patching submission (submission_id={submission_id})" return data, 500 + @authorize_submission_author def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given a submission ID @@ -270,4 +276,4 @@ def delete(self, submission_id: int) -> dict[str, any]: submissions_bp.add_url_rule( "/submissions/", view_func=SubmissionEndpoint.as_view("submission") -) +) \ No newline at end of file diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index cfaf63db..1e46994e 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -6,8 +6,9 @@ from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError -from project.db_in import db +from project import db from project.models.user import User as userModel +from project.utils.authentication import login_required, authorize_user, not_allowed users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -15,9 +16,11 @@ load_dotenv() API_URL = getenv("API_HOST") + class Users(Resource): """Api endpoint for the /users route""" + @login_required def get(self): """ This function will respond to get requests made to /users. @@ -43,7 +46,9 @@ def get(self): return {"message": "An error occurred while fetching the users", "url": f"{API_URL}/users"}, 500 + @not_allowed def post(self): + # TODO make it so this just creates a user for yourself """ This function will respond to post requests made to /users. It should create a new user and return a success message. @@ -80,10 +85,10 @@ def post(self): "url": f"{API_URL}/users"}, 500 - class User(Resource): """Api endpoint for the /users/{user_id} route""" + @login_required def get(self, user_id): """ This function will respond to GET requests made to /users/. @@ -100,6 +105,7 @@ def get(self, user_id): return {"message": "An error occurred while fetching the user", "url": f"{API_URL}/users"}, 500 + @not_allowed def patch(self, user_id): """ Update the user's information. @@ -131,6 +137,7 @@ def patch(self, user_id): "url": f"{API_URL}/users"}, 500 + @authorize_user def delete(self, user_id): """ This function will respond to DELETE requests made to /users/. diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py new file mode 100644 index 00000000..6501da9e --- /dev/null +++ b/backend/project/utils/authentication.py @@ -0,0 +1,395 @@ +""" +This module contains the functions to authenticate API calls. +""" +from os import getenv + +from dotenv import load_dotenv + +from functools import wraps +from flask import abort, request, make_response +import requests + +from project import db + +from project.models.user import User +from project.models.course import Course +from project.models.project import Project +from project.models.submission import Submission +from project.models.course_relation import CourseAdmin, CourseStudent +from sqlalchemy.exc import SQLAlchemyError + +load_dotenv() +API_URL = getenv("API_HOST") +AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") + + +def abort_with_message(code: int, message: str): + """Helper function to abort with a given status code and message""" + abort(make_response({"message": message}, code)) + + +def not_allowed(f): + """Decorator function to immediately abort the current request and return 403: Forbidden""" + @wraps(f) + def wrap(*args, **kwargs): + abort_with_message(403, "Forbidden action") + return wrap + + +def return_authenticated_user_id(): + """This function will authenticate the request and check whether the authenticated user + is already in the database, if not, they will be added + """ + authentication = request.headers.get("Authorization") + if not authentication: + abort_with_message(401, "No authorization given, you need an access token to use this API") + + auth_header = {"Authorization": authentication} + response = requests.get(AUTHENTICATION_URL, headers=auth_header) + if not response: + abort_with_message(401, "An error occured while trying to authenticate your access token") + if response.status_code != 200: + abort_with_message(401, response.json()["error"]) + + user_info = response.json() + auth_user_id = user_info["id"] + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return abort_with_message(500, "An unexpected database error occured while fetching the user") + + if user: + return auth_user_id + is_teacher = False + if user_info["jobTitle"] != None: + is_teacher = True + + # add user if not yet in database + try: + new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) + db.session.add(new_user) + db.session.commit() + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return abort_with_message(500, "An unexpected database error occured while creating the user during authentication") + return auth_user_id + + +def is_teacher(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + if not user: # should realistically never happen + abort(500, "A database error occured") + if user.is_teacher: + return True + return False + + +def is_teacher_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is the teacher of the course: course_id""" + try: + course = db.session.get(Course, course_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + if not course: + abort_with_message(404, f"Could not find course with id: {course_id}") + + if auth_user_id == course.teacher: + return True + + +def is_admin_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is an admin of the course: course_id""" + try: + course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + if course_admin: + return True + + return False + + +def is_student_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is a student of the course: course_id""" + try: + course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + if course_student: + return True + return False + + +def get_course_of_project(project_id): + """This function returns the course_id of the course associated with the project: project_id""" + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the project", + "url": f"{API_URL}/users"}, 500 + + if not project: + abort_with_message(404, f"Could not find project with id: {project_id}") + + return project.course_id + + +def project_visible(project_id): + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the project") + if not project: + abort_with_message(404, "Project with given id not found") + return project.visible_for_students + + +def get_course_of_submission(submission_id): + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + return get_course_of_project(submission.project_id) + + +def login_required(f): + """ + This function will check if the person sending a request to the API is logged in + and additionally create their user entry in the database if necessary + """ + @wraps(f) + def wrap(*args, **kwargs): + return_authenticated_user_id() + return f(*args, **kwargs) + return wrap + + +def authorize_teacher(f): + """ + This function will check if the person sending a request to the API is logged in and a teacher. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_teacher(auth_user_id): + kwargs["teacher_id"] = auth_user_id + return f(*args, **kwargs) + abort_with_message(403, "You are not authorized to perfom this action, only teachers are authorized") + return wrap + + +def authorize_teacher_of_course(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course in the request. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_teacher_of_course(auth_user_id, kwargs["course_id"]): + return f(*args, **kwargs) + + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_teacher_or_course_admin(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course in the request or an admin of this course. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + course_id = kwargs["course_id"] + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, only teachers and course admins are authorized") + return wrap + + +def authorize_user(f): + """ + This function will check if the person sending a request to the API is logged in, + and the same user that the request is about. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + user_id = kwargs["user_id"] + if auth_user_id == user_id: + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, you are not this user") + return wrap + + +def authorize_teacher_of_project(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + + if is_teacher_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, you are not the teacher of this project") + return wrap + + +def authorize_teacher_or_project_admin(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher or an admin of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, """You are not authorized to perfom this action, + you are not the teacher or an admin of this project""") + return wrap + + +def authorize_project_visible(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course which the project in the request belongs to. + Or if the person is a student of this course, it will return the project if it is visible for students. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + +def authorize_submissions_request(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = request.args["project_id"] + course_id = get_course_of_project(project_id) + + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.args.get("uid"): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_student_submission(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = request.form["project_id"] + course_id = get_course_of_project(project_id) + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.form.get("uid"): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_submission_author(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + submission_id = kwargs["submission_id"] + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + if submission.uid == auth_user_id: + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_grader(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + course_id = get_course_of_submission(kwargs["submission_id"]) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_submission_request(f): + @wraps(f) + def wrap(*args, **kwargs): + # submission_author / grader mag hier aan + auth_user_id = return_authenticated_user_id() + submission_id = kwargs["submission_id"] + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + if submission.uid == auth_user_id: + return f(*args, **kwargs) + course_id = get_course_of_project(submission.project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..d9f7d9cd 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -232,4 +232,4 @@ def patch_by_id_from_model(model: DeclarativeMeta, "url": urljoin(f"{base_url}/", str(column_id))}), 200 except SQLAlchemyError: return {"error": "Something went wrong while updating the database.", - "url": base_url}, 500 + "url": base_url}, 500 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 0076b0f8..9e9dc90a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,5 @@ flask-sqlalchemy python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 -SQLAlchemy~=2.0.27 \ No newline at end of file +SQLAlchemy~=2.0.27 +requests~=2.25.1 \ No newline at end of file diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py new file mode 100644 index 00000000..2544968d --- /dev/null +++ b/backend/test_auth_server/__main__.py @@ -0,0 +1,69 @@ +"""Main entry point for the application.""" + +from dotenv import load_dotenv +from flask import Flask + +"""Index api point""" +from flask import Blueprint, request +from flask_restful import Resource, Api + +index_bp = Blueprint("index", __name__) +index_endpoint = Api(index_bp) + +token_dict = { + "teacher1":{ + "id":"Gunnar", + "jobTitle":"teacher" + }, + "teacher2":{ + "id":"Bart", + "jobTitle":"teacher" + }, + "student1":{ + "id":"w_student", + "jobTitle":None + }, + "student01":{ + "id":"student01", + "jobTitle":None + }, + "course_admin1":{ + "id":"Rien", + "jobTitle":None + }, + "del_user":{ + "id":"del", + "jobTitle":None + }, + "ad3_teacher":{ + "id":"brinkmann", + "jobTitle0":"teacher" + }, + "student02":{ + "id":"student02", + "jobTitle":None + }, +} + +class Index(Resource): + """Api endpoint for the / route""" + + def get(self): + auth = request.headers.get("Authorization") + if not auth: + return {"error":"Please give authorization"}, 401 + if auth in token_dict.keys(): + return token_dict[auth], 200 + return {"error":"Wrong address"}, 401 + + +index_bp.add_url_rule("/", view_func=Index.as_view("index")) + +if __name__ == "__main__": + load_dotenv() + + app = Flask(__name__) + app.register_blueprint(index_bp) + + app.run(debug=True, host='0.0.0.0') + diff --git a/backend/tests.yaml b/backend/tests.yaml index fd6d7a16..d1a41efb 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -15,6 +15,16 @@ services: start_period: 5s volumes: - ./db_construct.sql:/docker-entrypoint-initdb.d/init.sql + auth-server: + build: + context: . + dockerfile: ./Dockerfile_auth_test + environment: + API_HOST: http://auth-server + volumes: + - .:/auth-app + command: ["test_auth_server"] + test-runner: build: @@ -23,12 +33,15 @@ services: depends_on: postgres: condition: service_healthy + auth-server: + condition: service_started environment: POSTGRES_HOST: postgres # Use the service name defined in Docker Compose POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here + AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments volumes: - .:/app diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aebe7ce9..7be87a8c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -153,4 +153,4 @@ def session(): # Truncate all tables for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) - session.commit() + session.commit() \ No newline at end of file diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index c9b64e15..3d5e199f 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -8,14 +8,14 @@ def test_post_courses(self, client, valid_course): Test posting a course to the /courses endpoint """ - response = client.post("/courses", json=valid_course) + response = client.post("/courses", json=valid_course, headers={"Authorization":"teacher2"}) assert response.status_code == 201 data = response.json assert data["data"]["name"] == "Sel" assert data["data"]["teacher"] == valid_course["teacher"] # Is reachable using the API - get_response = client.get(f"/courses/{data['data']['course_id']}") + get_response = client.get(f"/courses/{data['data']['course_id']}", headers={"Authorization":"teacher2"}) assert get_response.status_code == 200 @@ -32,7 +32,7 @@ def test_post_courses_course_id_students_and_admins( response = client.post( sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, + json={"students": valid_students}, headers={"Authorization":"teacher2"} ) assert response.status_code == 403 @@ -43,7 +43,7 @@ def test_get_courses(self, valid_course_entries, client): Test all the getters for the courses endpoint """ - response = client.get("/courses") + response = client.get("/courses", headers={"Authorization":"teacher1"}) assert response.status_code == 200 data = response.json for course in valid_course_entries: @@ -53,12 +53,12 @@ def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" response = client.delete( - "/courses/" + str(valid_course_entry.course_id), + "/courses/" + str(valid_course_entry.course_id), headers={"Authorization":"teacher2"} ) assert response.status_code == 200 # Is not reachable using the API - get_response = client.get(f"/courses/{valid_course_entry.course_id}") + get_response = client.get(f"/courses/{valid_course_entry.course_id}", headers={"Authorization":"teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): @@ -67,7 +67,7 @@ def test_course_patch(self, valid_course_entry, client): """ response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ "name": "TestTest" - }) + }, headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 6ca89968..f199ab06 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -11,38 +11,33 @@ class TestCourseShareLinks: def test_get_share_links(self, valid_course_entry, client): """Test whether the share links are accessible""" - response = client.get(f"courses/{valid_course_entry.course_id}/join_codes") + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_post_share_links(self, valid_course_entry, client): """Test whether the share links are accessible to post to""" response = client.post( f"courses/{valid_course_entry.course_id}/join_codes", - json={"for_admins": True}) + json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 201 def test_delete_share_links(self, share_code_admin, client): """Test whether the share links are accessible to delete""" response = client.delete( - f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}") + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_get_share_links_404(self, client): """Test whether the share links are accessible""" - response = client.get("courses/0/join_codes") + response = client.get("courses/0/join_codes", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_post_share_links_404(self, client): """Test whether the share links are accessible to post to""" - response = client.post("courses/0/join_codes", json={"for_admins": True}) - assert response.status_code == 404 - - def test_delete_share_links_404(self, client): - """Test whether the share links are accessible to delete""" - response = client.delete("courses/0/join_codes/0") + response = client.post("courses/0/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_for_admins_required(self, valid_course_entry, client): """Test whether the for_admins field is required""" - response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}, headers={"Authorization":"teacher2"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 24fcc2d0..fb9be82c 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -11,11 +11,12 @@ def test_assignment_download(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data' + content_type='multipart/form-data', + headers={"Authorization":"teacher2"} ) assert response.status_code == 201 project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignments") + response = client.get(f"/projects/{project_id}/assignments", headers={"Authorization":"teacher2"}) # file downloaded succesfully assert response.status_code == 200 @@ -26,19 +27,19 @@ def test_not_found_download(client): """ response = client.get("/projects") # get an index that doesnt exist - response = client.get("/projects/-1/assignments") + response = client.get("/projects/-1/assignments", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_projects_home(client): """Test home project endpoint.""" - response = client.get("/projects") + response = client.get("/projects", headers={"Authorization":"teacher1"}) assert response.status_code == 200 def test_getting_all_projects(client): """Test getting all projects""" - response = client.get("/projects") + response = client.get("/projects", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert isinstance(response.json['data'], list) @@ -52,14 +53,14 @@ def test_post_project(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data' + content_type='multipart/form-data', headers={"Authorization":"teacher2"} ) assert response.status_code == 201 # check if the project with the id is present project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 @@ -67,16 +68,16 @@ def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" project_id = valid_project_entry.project_id - response = client.delete(f"/projects/{project_id}") + response = client.delete(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.get(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_patch_project(client, valid_project_entry): """ - Test functionality of the PUT method for projects + Test functionality of the PATCH method for projects """ project_id = valid_project_entry.project_id @@ -86,6 +87,6 @@ def test_patch_project(client, valid_project_entry): response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived - }) + }, headers={"Authorization":"teacher2"}) assert response.status_code == 200 diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index be36592f..60fd971a 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -14,186 +14,36 @@ class TestSubmissionsEndpoint: ### GET SUBMISSIONS ### def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=-20") + response = client.get("/submissions?uid=-20", headers={"Authorization":"teacher1"}) assert response.status_code == 400 def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" - response = client.get("/submissions?project_id=-1") - assert response.status_code == 400 + response = client.get("/submissions?project_id=-1", headers={"Authorization":"teacher1"}) + assert response.status_code == 404 # can't find course of project in authorization assert "message" in response.json def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" - response = client.get("/submissions?project_id=zero") + response = client.get("/submissions?project_id=zero", headers={"Authorization":"teacher1"}) assert response.status_code == 400 assert "message" in response.json - def test_get_submissions_all(self, client: FlaskClient): - """Test getting the submissions""" - response = client.get("/submissions") - data = response.json - assert response.status_code == 200 - assert "message" in data - assert isinstance(data["data"], list) - - def test_get_submissions_user(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific user""" - response = client.get(f"/submissions?uid={valid_submission_entry.uid}") - data = response.json - assert response.status_code == 200 - assert "message" in data - - def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}") - data = response.json - assert response.status_code == 200 - assert "message" in data - - def test_get_submissions_user_project(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific user and project""" - response = client.get( - f"/submissions? \ - uid={valid_submission_entry.uid}&\ - project_id={valid_submission_entry.project_id}") + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert "message" in data - ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, valid_project_entry, files): - """Test posting a submission without specifying a user""" - response = client.post("/submissions", data={ - "project_id": valid_project_entry.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "The uid is missing" - - def test_post_submissions_wrong_user(self, client: FlaskClient, valid_project_entry, files): - """Test posting a submission for a non-existing user""" - response = client.post("/submissions", data={ - "uid": "unknown", - "project_id": valid_project_entry.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid user (uid=unknown)" - - def test_post_submissions_no_project(self, client: FlaskClient, valid_user_entry, files): - """Test posting a submission without specifying a project""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "The project_id is missing" - - def test_post_submissions_wrong_project(self, client: FlaskClient, valid_user_entry, files): - """Test posting a submission for a non-existing project""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": 0, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=0)" - - def test_post_submissions_wrong_project_type( - self, client: FlaskClient, valid_user_entry, files - ): - """Test posting a submission for a non-existing project of the wrong type""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": "zero", - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid project_id typing (project_id=zero)" - - def test_post_submissions_no_files( - self, client: FlaskClient, valid_user_entry, valid_project_entry): - """Test posting a submission when no files are uploaded""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": valid_project_entry.project_id - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - - def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): - """Test posting a submission for an empty file""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": file_empty - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - def test_post_submissions_file_with_no_name( - self, client: FlaskClient, session: Session, file_no_name - ): - """Test posting a submission for a file without a name""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": file_no_name - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - def test_post_submissions_missing_required_files( - self, client: FlaskClient, session: Session, files - ): - """Test posting a submissions for a file with a wrong name""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Not all required files were uploaded" - - def test_post_submissions_correct( - self, client: FlaskClient, session: Session, files - ): - """Test posting a submission""" - project = session.query(Project).filter_by(title="Predicaten").first() - response = client.post("/submissions", data={ - "uid": "student02", - "project_id": project.project_id, - "files": files - }) - data = response.json - assert response.status_code == 201 - assert data["message"] == "Successfully fetched the submissions" - assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" - assert data["data"]["user"] == f"{API_HOST}/users/student02" - assert data["data"]["project"] == f"{API_HOST}/projects/{project.project_id}" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - response = client.get("/submissions/0") + response = client.get("/submissions/0", headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" @@ -201,7 +51,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.get(f"/submissions/{submission.submission_id}") + response = client.get(f"/submissions/{submission.submission_id}", headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" @@ -218,10 +68,10 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/0", data={"grading": 20}) + response = client.patch("/submissions/0", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" @@ -229,7 +79,7 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}) + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -240,18 +90,18 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}) + response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" - def test_patch_submission_correct(self, client: FlaskClient, session: Session): + def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): """Test patching a submission""" project = session.query(Project).filter_by(title="B+ Trees").first() submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}) + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" @@ -265,14 +115,16 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): "path": "/submissions/2", "status": False } + + # TODO test course admin (allowed) and student (not allowed) patch ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - response = client.delete("submissions/0") + response = client.delete("submissions/0", headers={"Authorization":"student01"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" @@ -280,7 +132,7 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.delete(f"submissions/{submission.submission_id}") + response = client.delete(f"submissions/{submission.submission_id}", headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c20b0a29..25b28d62 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -37,48 +37,82 @@ def user_db_session(): for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() + class TestUserEndpoint: """Class to test user management endpoints.""" def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}") + response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"student1"}) assert response.status_code == 200 - get_response = client.get(f"/users/{valid_user_entry.uid}") + # If student 1 sends this request, he would get added again + get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + assert get_response.status_code == 404 + + def test_delete_user_not_yourself(self, client, valid_user_entry): + """Test deleting a user that is not the user the authentication belongs to.""" + # Delete the user + response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + assert response.status_code == 403 + + # If student 1 sends this request, he would get added again + get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + + assert get_response.status_code == 200 def test_delete_not_present(self, client): """Test deleting a user that does not exist.""" - response = client.delete("/users/-20") - assert response.status_code == 404 + response = client.delete("/users/-20", headers={"Authorization":"student1"}) + assert response.status_code == 403 # User does not exist, so you are not the user - def test_wrong_form_post(self, client, user_invalid_field): - """Test posting with a wrong form.""" + def test_post_no_authentication(self, client, user_invalid_field): + """Test posting without authentication.""" response = client.post("/users", json=user_invalid_field) - assert response.status_code == 400 + assert response.status_code == 403 # POST to /users is not allowed - def test_wrong_datatype_post(self, client, valid_user): - """Test posting with a wrong content type.""" - response = client.post("/users", data=valid_user) - assert response.status_code == 415 + def test_post_authenticated(self, client, valid_user): + """Test posting with wrong authentication.""" + response = client.post("/users", data=valid_user, headers={"Authorization":"teacher1"}) + assert response.status_code == 403 # POST to /users is not allowed def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" - response = client.get("/users") + response = client.get("/users", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) for valid_user in valid_user_entries: assert valid_user.uid in \ [user["uid"] for user in response.json["data"]] + + def test_get_all_users_no_authentication(self, client): + """Test getting all users without authentication.""" + response = client.get("/users") + assert response.status_code == 401 + + def test_get_all_users_wrong_authentication(self, client): + """Test getting all users with wrong authentication.""" + response = client.get("/users", headers={"Authorization":"wrong"}) + assert response.status_code == 401 def test_get_one_user(self, client, valid_user_entry): """Test getting a single user.""" - response = client.get(f"users/{valid_user_entry.uid}") + response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert "data" in response.json + + def test_get_one_user_no_authentication(self, client, valid_user_entry): + """Test getting a single user without authentication.""" + response = client.get(f"users/{valid_user_entry.uid}") + assert response.status_code == 401 + + def test_get_one_user_wrong_authentication(self, client, valid_user_entry): + """Test getting a single user with wrong authentication.""" + response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) + assert response.status_code == 401 def test_patch_user(self, client, valid_user_entry): """Test updating a user.""" @@ -89,12 +123,7 @@ def test_patch_user(self, client, valid_user_entry): 'is_teacher': new_is_teacher, 'is_admin': not valid_user_entry.is_admin }) - assert response.status_code == 200 - assert response.json["message"] == "User updated successfully!" - - get_response = client.get(f"/users/{valid_user_entry.uid}") - assert get_response.status_code == 200 - assert get_response.json["data"]["is_teacher"] == new_is_teacher + assert response.status_code == 403 # Patching a user is never necessary and thus not allowed def test_patch_non_existent(self, client): """Test updating a non-existent user.""" @@ -102,19 +131,19 @@ def test_patch_non_existent(self, client): 'is_teacher': False, 'is_admin': True }) - assert response.status_code == 404 + assert response.status_code == 403 # Patching is not allowed def test_patch_non_json(self, client, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) - assert response.status_code == 415 + assert response.status_code == 403 # Patching is not allowed def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false") + response = client.get("/users?is_admin=true&is_teacher=false", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query From 4e34eef1521c034d566aed75f40be89718f0e1b5 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:38:21 +0100 Subject: [PATCH 130/144] Fix #114 (#115) * Fix #114 * test 1 * Fix #114 try 2 * fixed misnamed runner * removed trailing new line --- .github/workflows/ci-linter-backend.yaml | 5 ++++- .github/workflows/ci-linter-frontend.yaml | 5 ++++- .github/workflows/ci-test-backend.yaml | 5 ++++- .github/workflows/ci-test-frontend.yaml | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-linter-backend.yaml b/.github/workflows/ci-linter-backend.yaml index d9bb1150..401e96fb 100644 --- a/.github/workflows/ci-linter-backend.yaml +++ b/.github/workflows/ci-linter-backend.yaml @@ -1,6 +1,9 @@ name: UGent-3-backend-linter run-name: ${{ github.actor }} is running backend linter 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'backend/**' jobs: Backend-tests: runs-on: self-hosted diff --git a/.github/workflows/ci-linter-frontend.yaml b/.github/workflows/ci-linter-frontend.yaml index f93d6325..8efab247 100644 --- a/.github/workflows/ci-linter-frontend.yaml +++ b/.github/workflows/ci-linter-frontend.yaml @@ -1,6 +1,9 @@ name: UGent-3-frontend-linter run-name: ${{ github.actor }} is running frontend linter 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'frontend/**' jobs: Frontend-tests: runs-on: self-hosted diff --git a/.github/workflows/ci-test-backend.yaml b/.github/workflows/ci-test-backend.yaml index 5335aa08..0b2faf75 100644 --- a/.github/workflows/ci-test-backend.yaml +++ b/.github/workflows/ci-test-backend.yaml @@ -1,6 +1,9 @@ name: UGent-3-backend-test run-name: ${{ github.actor }} is running backend tests 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'backend/**' jobs: Backend-tests: runs-on: self-hosted diff --git a/.github/workflows/ci-test-frontend.yaml b/.github/workflows/ci-test-frontend.yaml index 8d976eb4..210ec6d0 100644 --- a/.github/workflows/ci-test-frontend.yaml +++ b/.github/workflows/ci-test-frontend.yaml @@ -1,6 +1,9 @@ name: UGent-3-frontend-test run-name: ${{ github.actor }} is running frontend tests 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'frontend/**' jobs: Frontend-tests: runs-on: self-hosted From 988da504688218c256a1014bf43f36a535aa893b Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:13:43 +0100 Subject: [PATCH 131/144] Merge cleanup of authentication and tests into development (#111) * start authentication * authentication start decorators * login_required should work with access token * backend authentication for most endpoints (very rough draft of functions in authentication.py) * clean_up_function * authentication cleanup * give error when access_token fails * documentation auth functions * fixed imports * actual import fix * added requests * authorize submissions * removed double checks * start testing setup backend authentication * poging testen * github tests check * user tests with authentication * auth url accessible hopefully * change authorization to be easier to deal with since it doesn't matter for tests * fixed jobCategory -> jobTitle * fix authentication * user tests zouden moeten slagen * fix authentication arguments * project tests with authentication * changed auth server id of teacher * maybe correct primary keys * second try on primary key of course relations * further test authentication * authentication on project assignment files * auth on course_join_codes and extra tests * teacher_id in function when necessary * user tests with authentication * extra testing * fixed comments * lots of testing changes * should be 1 error test now * fix tests * small linter fix * should fix the linter * actually fixed this time * linter all return statements * allow admins to patch users * should pass all tests * mergeable * grouped imports * made requested changes * requested changes * line too long * unused imports * added models folder to utils --- .../courses/course_admin_relation.py | 7 +- .../endpoints/courses/course_details.py | 2 +- .../courses/course_student_relation.py | 2 +- backend/project/endpoints/courses/courses.py | 2 +- .../endpoints/courses/courses_utils.py | 2 +- .../courses/join_codes/course_join_code.py | 4 +- .../courses/join_codes/course_join_codes.py | 4 +- .../courses/join_codes/join_codes_utils.py | 14 - .../endpoints/projects/project_detail.py | 3 +- backend/project/endpoints/submissions.py | 6 +- backend/project/endpoints/users.py | 16 +- backend/project/utils/authentication.py | 282 +++++++----------- backend/project/utils/models/course_utils.py | 67 +++++ backend/project/utils/models/project_utils.py | 41 +++ .../project/utils/models/submission_utils.py | 33 ++ backend/project/utils/models/user_utils.py | 36 +++ backend/project/utils/query_agent.py | 2 +- backend/test_auth_server/__main__.py | 23 +- backend/tests/conftest.py | 2 +- backend/tests/endpoints/conftest.py | 31 +- .../tests/endpoints/course/courses_test.py | 6 +- .../tests/endpoints/course/share_link_test.py | 22 +- backend/tests/endpoints/project_test.py | 7 +- backend/tests/endpoints/submissions_test.py | 29 +- backend/tests/endpoints/user_test.py | 65 ++-- 25 files changed, 434 insertions(+), 274 deletions(-) delete mode 100644 backend/project/endpoints/courses/join_codes/join_codes_utils.py create mode 100644 backend/project/utils/models/course_utils.py create mode 100644 backend/project/utils/models/project_utils.py create mode 100644 backend/project/utils/models/submission_utils.py create mode 100644 backend/project/utils/models/user_utils.py diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index bd8e1fa6..43c1cf1e 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import abort, request +from flask import request from flask_restful import Resource from project.models.course_relation import CourseAdmin @@ -21,11 +21,12 @@ json_message ) from project.utils.query_agent import query_selected_from_model, insert_into_model -from project.utils.authentication import login_required, authorize_teacher_of_course, authorize_teacher_or_course_admin +from project.utils.authentication import authorize_teacher_of_course, \ + authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseForAdmins(Resource): """ diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 56751c3d..cfbfe5ca 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -23,7 +23,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseByCourseId(Resource): """Api endpoint for the /courses/course_id link""" diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 31e9c28c..369fc4c2 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -30,7 +30,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseToAddStudents(Resource): """ diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index a56541e7..7b494b04 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -18,7 +18,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseForUser(Resource): """Api endpoint for the /courses link""" diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index 0489e775..cb36c6a4 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -17,7 +17,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") def execute_query_abort_if_db_error(query, url, query_all=False): """ diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py index 97f7284d..c5cbfb17 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_code.py +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -9,7 +9,6 @@ from flask_restful import Resource from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model from project.models.course_share_code import CourseShareCode -from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists from project.utils.authentication import authorize_teacher_of_course load_dotenv() @@ -22,7 +21,7 @@ class CourseJoinCode(Resource): the /courses/course_id/join_codes/ url, only an admin of a course can do this """ - @check_course_exists + @authorize_teacher_of_course def get(self, course_id, join_code): """ This function will return all the join codes of a course @@ -35,7 +34,6 @@ def get(self, course_id, join_code): urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes") ) - @check_course_exists @authorize_teacher_of_course def delete(self, course_id, join_code): """ diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index 103de7db..a2401783 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -11,7 +11,7 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.courses_utils import get_course_abort_if_not_found -from project.utils.authentication import login_required, authorize_teacher_of_course +from project.utils.authentication import authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -23,7 +23,7 @@ class CourseJoinCodes(Resource): the /courses/course_id/join_codes url, only an admin of a course can do this """ - @login_required + @authorize_teacher_of_course def get(self, course_id): """ This function will return all the join codes of a course diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py deleted file mode 100644 index 65defbb4..00000000 --- a/backend/project/endpoints/courses/join_codes/join_codes_utils.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -This module contains functions that are used by the join codes resources. -""" - -from project.endpoints.courses.courses_utils import get_course_abort_if_not_found - -def check_course_exists(func): - """ - Middleware to check if the course exists before handling the request - """ - def wrapper(*args, **kwargs): - get_course_abort_if_not_found(kwargs["course_id"]) - return func(*args, **kwargs) - return wrapper diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 691aacf0..c9d9dc03 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -12,7 +12,8 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model -from project.utils.authentication import authorize_teacher_or_project_admin, authorize_teacher_of_project, authorize_project_visible +from project.utils.authentication import authorize_teacher_or_project_admin, \ + authorize_teacher_of_project, authorize_project_visible API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 34ae2282..a12aa578 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -14,7 +14,9 @@ from project.utils.files import filter_files, all_files_uploaded, zip_files from project.utils.user import is_valid_user from project.utils.project import is_valid_project -from project.utils.authentication import authorize_submission_request, authorize_submissions_request, authorize_grader, authorize_student_submission, authorize_submission_author +from project.utils.authentication import authorize_submission_request, \ + authorize_submissions_request, authorize_grader, \ + authorize_student_submission, authorize_submission_author load_dotenv() API_HOST = getenv("API_HOST") @@ -276,4 +278,4 @@ def delete(self, submission_id: int) -> dict[str, any]: submissions_bp.add_url_rule( "/submissions/", view_func=SubmissionEndpoint.as_view("submission") -) \ No newline at end of file +) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 1e46994e..7d073c6c 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -8,7 +8,8 @@ from project import db from project.models.user import User as userModel -from project.utils.authentication import login_required, authorize_user, not_allowed +from project.utils.authentication import login_required, authorize_user, \ + authorize_admin, not_allowed users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -48,7 +49,6 @@ def get(self): @not_allowed def post(self): - # TODO make it so this just creates a user for yourself """ This function will respond to post requests made to /users. It should create a new user and return a success message. @@ -56,6 +56,7 @@ def post(self): uid = request.json.get('uid') is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') + url = f"{API_URL}/users" if is_teacher is None or is_admin is None or uid is None: return { @@ -64,25 +65,24 @@ def post(self): "uid": "User ID (string)", "is_teacher": "Teacher status (boolean)", "is_admin": "Admin status (boolean)" - },"url": f"{API_URL}/users" + },"url": url }, 400 try: user = db.session.get(userModel, uid) if user is not None: - # bad request, error code could be 409 but is rarely used + # Bad request, error code could be 409 but is rarely used return {"message": f"User {uid} already exists"}, 400 # Code to create a new user in the database using the uid, is_teacher, and is_admin new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) db.session.commit() return jsonify({"message": "User created successfully!", - "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 201}) + "data": user, "url": f"{url}/{user.uid}", "status_code": 201}) except SQLAlchemyError: - # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while creating the user", - "url": f"{API_URL}/users"}, 500 + "url": url}, 500 class User(Resource): @@ -105,7 +105,7 @@ def get(self, user_id): return {"message": "An error occurred while fetching the user", "url": f"{API_URL}/users"}, 500 - @not_allowed + @authorize_admin def patch(self, user_id): """ Update the user's information. diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 6501da9e..61b64e61 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -3,36 +3,33 @@ """ from os import getenv +from functools import wraps + from dotenv import load_dotenv -from functools import wraps from flask import abort, request, make_response import requests +from sqlalchemy.exc import SQLAlchemyError from project import db from project.models.user import User -from project.models.course import Course -from project.models.project import Project -from project.models.submission import Submission -from project.models.course_relation import CourseAdmin, CourseStudent -from sqlalchemy.exc import SQLAlchemyError +from project.utils.models.course_utils import is_admin_of_course, \ + is_student_of_course, is_teacher_of_course +from project.utils.models.project_utils import get_course_of_project, project_visible +from project.utils.models.submission_utils import get_submission, get_course_of_submission +from project.utils.models.user_utils import is_admin, is_teacher load_dotenv() API_URL = getenv("API_HOST") AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") -def abort_with_message(code: int, message: str): - """Helper function to abort with a given status code and message""" - abort(make_response({"message": message}, code)) - - def not_allowed(f): """Decorator function to immediately abort the current request and return 403: Forbidden""" @wraps(f) def wrap(*args, **kwargs): - abort_with_message(403, "Forbidden action") + return {"message":"Forbidden action"}, 403 return wrap @@ -42,144 +39,47 @@ def return_authenticated_user_id(): """ authentication = request.headers.get("Authorization") if not authentication: - abort_with_message(401, "No authorization given, you need an access token to use this API") - + abort(make_response(({"message": + "No authorization given, you need an access token to use this API"} + , 401))) + auth_header = {"Authorization": authentication} - response = requests.get(AUTHENTICATION_URL, headers=auth_header) - if not response: - abort_with_message(401, "An error occured while trying to authenticate your access token") - if response.status_code != 200: - abort_with_message(401, response.json()["error"]) + try: + response = requests.get(AUTHENTICATION_URL, headers=auth_header, timeout=5) + except TimeoutError: + abort(make_response(({"message":"Request to Microsoft timed out"} + , 500))) + if not response or response.status_code != 200: + abort(make_response(({"message": + "An error occured while trying to authenticate your access token"} + , 401))) user_info = response.json() auth_user_id = user_info["id"] try: user = db.session.get(User, auth_user_id) except SQLAlchemyError: - # every exception should result in a rollback db.session.rollback() - return abort_with_message(500, "An unexpected database error occured while fetching the user") - + abort(make_response(({"message": + "An unexpected database error occured while fetching the user"}, 500))) + if user: return auth_user_id is_teacher = False - if user_info["jobTitle"] != None: + if user_info["jobTitle"] is not None: is_teacher = True - + # add user if not yet in database try: new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) db.session.add(new_user) db.session.commit() except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return abort_with_message(500, "An unexpected database error occured while creating the user during authentication") - return auth_user_id - - -def is_teacher(auth_user_id): - """This function checks whether the user with auth_user_id is a teacher""" - try: - user = db.session.get(User, auth_user_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - if not user: # should realistically never happen - abort(500, "A database error occured") - if user.is_teacher: - return True - return False - - -def is_teacher_of_course(auth_user_id, course_id): - """This function checks whether the user with auth_user_id is the teacher of the course: course_id""" - try: - course = db.session.get(Course, course_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - - if not course: - abort_with_message(404, f"Could not find course with id: {course_id}") - - if auth_user_id == course.teacher: - return True - - -def is_admin_of_course(auth_user_id, course_id): - """This function checks whether the user with auth_user_id is an admin of the course: course_id""" - try: - course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - - if course_admin: - return True - - return False - - -def is_student_of_course(auth_user_id, course_id): - """This function checks whether the user with auth_user_id is a student of the course: course_id""" - try: - course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - if course_student: - return True - return False - - -def get_course_of_project(project_id): - """This function returns the course_id of the course associated with the project: project_id""" - try: - project = db.session.get(Project, project_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the project", - "url": f"{API_URL}/users"}, 500 - - if not project: - abort_with_message(404, f"Could not find project with id: {project_id}") - - return project.course_id - - -def project_visible(project_id): - try: - project = db.session.get(Project, project_id) - except SQLAlchemyError: - # every exception should result in a rollback db.session.rollback() - abort_with_message(500, "An error occurred while fetching the project") - if not project: - abort_with_message(404, "Project with given id not found") - return project.visible_for_students - - -def get_course_of_submission(submission_id): - try: - submission = db.session.get(Submission, submission_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - abort_with_message(500, "An error occurred while fetching the submission") - if not submission: - abort_with_message(404, f"Submission with id: {submission_id} not found") - return get_course_of_project(submission.project_id) - + abort(make_response(({"message": + """An unexpected database error occured + while creating the user during authentication"""}, 500))) + return auth_user_id def login_required(f): """ @@ -193,6 +93,22 @@ def wrap(*args, **kwargs): return wrap +def authorize_admin(f): + """ + This function will check if the person sending a request to the API is logged in and an admin. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_admin(auth_user_id): + return f(*args, **kwargs) + abort(make_response(({"message": + """You are not authorized to perfom this action, + only admins are authorized"""}, 403))) + return wrap + + def authorize_teacher(f): """ This function will check if the person sending a request to the API is logged in and a teacher. @@ -204,7 +120,9 @@ def wrap(*args, **kwargs): if is_teacher(auth_user_id): kwargs["teacher_id"] = auth_user_id return f(*args, **kwargs) - abort_with_message(403, "You are not authorized to perfom this action, only teachers are authorized") + abort(make_response(({"message": + """You are not authorized to perfom this action, + only teachers are authorized"""}, 403))) return wrap @@ -220,7 +138,7 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, kwargs["course_id"]): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap @@ -234,10 +152,12 @@ def authorize_teacher_or_course_admin(f): def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = kwargs["course_id"] - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, "You are not authorized to perfom this action, only teachers and course admins are authorized") + abort(make_response(({"message":"""You are not authorized to perfom this action, + only teachers and course admins are authorized"""}, 403))) return wrap @@ -253,8 +173,9 @@ def wrap(*args, **kwargs): user_id = kwargs["user_id"] if auth_user_id == user_id: return f(*args, **kwargs) - - abort_with_message(403, "You are not authorized to perfom this action, you are not this user") + + abort(make_response(({"message":"""You are not authorized to perfom this action, + you are not this user"""}, 403))) return wrap @@ -269,11 +190,12 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) - + if is_teacher_of_course(auth_user_id, course_id): return f(*args, **kwargs) - abort_with_message(403, "You are not authorized to perfom this action, you are not the teacher of this project") + abort(make_response(({"message":"""You are not authorized to perfom this action, + you are not the teacher of this project"""}, 403))) return wrap @@ -288,10 +210,11 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, """You are not authorized to perfom this action, - you are not the teacher or an admin of this project""") + abort(make_response(({"message":"""You are not authorized to perfom this action, + you are not the teacher or an admin of this project"""}, 403))) return wrap @@ -299,7 +222,8 @@ def authorize_project_visible(f): """ This function will check if the person sending a request to the API is logged in, and the teacher of the course which the project in the request belongs to. - Or if the person is a student of this course, it will return the project if it is visible for students. + Or if the person is a student of this course, + it will return the project if it is visible for students. Returns 403: Not Authorized if either condition is false """ @wraps(f) @@ -307,89 +231,101 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_submissions_request(f): + """This function will check if the person sending a request to the API is logged in, + and either the teacher/admin of the course or the student + that the submission belongs to + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = request.args["project_id"] course_id = get_course_of_project(project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - - if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.args.get("uid"): + + if (is_student_of_course(auth_user_id, course_id) + and project_visible(project_id) + and auth_user_id == request.args.get("uid")): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_student_submission(f): + """This function will check if the person sending a request to the API is logged in, + and a student of the course they're trying to post a submission to + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = request.form["project_id"] course_id = get_course_of_project(project_id) - if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.form.get("uid"): + if (is_student_of_course(auth_user_id, course_id) + and project_visible(project_id) + and auth_user_id == request.form.get("uid")): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_submission_author(f): + """This function will check if the person sending a request to the API is logged in, + and the original author of the submission + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() submission_id = kwargs["submission_id"] - try: - submission = db.session.get(Submission, submission_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - abort_with_message(500, "An error occurred while fetching the submission") - if not submission: - abort_with_message(404, f"Submission with id: {submission_id} not found") + submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_grader(f): + """This function will check if the person sending a request to the API is logged in, + and either the teacher/admin of the course. + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = get_course_of_submission(kwargs["submission_id"]) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_submission_request(f): + """This function will check if the person sending a request to the API is logged in, + and either the teacher/admin of the course or the student + that the submission belongs to + """ @wraps(f) def wrap(*args, **kwargs): - # submission_author / grader mag hier aan auth_user_id = return_authenticated_user_id() submission_id = kwargs["submission_id"] - try: - submission = db.session.get(Submission, submission_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - abort_with_message(500, "An error occurred while fetching the submission") - if not submission: - abort_with_message(404, f"Submission with id: {submission_id} not found") + submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) course_id = get_course_of_project(submission.project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message": + "You're not authorized to perform this action"} + , 403))) return wrap diff --git a/backend/project/utils/models/course_utils.py b/backend/project/utils/models/course_utils.py new file mode 100644 index 00000000..6ea09afd --- /dev/null +++ b/backend/project/utils/models/course_utils.py @@ -0,0 +1,67 @@ +"""This module contains helper functions related to courses for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.course import Course +from project.models.course_relation import CourseAdmin, CourseStudent + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_course(course_id): + """Returns the course associated with course_id or the appropriate error""" + try: + course = db.session.get(Course, course_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500))) + + if not course: + abort(make_response(({"message":f"Course with id: {course_id} not found"}, 404))) + return course + +def is_teacher_of_course(auth_user_id, course_id): + """This function checks whether the user + with auth_user_id is the teacher of the course: course_id + """ + course = get_course(course_id) + if auth_user_id == course.teacher: + return True + return False + + +def is_admin_of_course(auth_user_id, course_id): + """This function checks whether the user + with auth_user_id is an admin of the course: course_id + """ + try: + course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500))) + + if course_admin: + return True + return False + +def is_student_of_course(auth_user_id, course_id): + """This function checks whether the user + with auth_user_id is a student of the course: course_id + """ + try: + course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500))) + if course_student: + return True + return False diff --git a/backend/project/utils/models/project_utils.py b/backend/project/utils/models/project_utils.py new file mode 100644 index 00000000..2e2b9f17 --- /dev/null +++ b/backend/project/utils/models/project_utils.py @@ -0,0 +1,41 @@ +"""This module contains helper functions related to projects for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.project import Project + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_project(project_id): + """Returns the project associated with project_id or the appropriate error""" + if isinstance(project_id, str) and not project_id.isnumeric(): + abort(make_response(({"message": f"{project_id} is not a valid project id"} + , 400))) + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the project"} + , 500))) + + if not project: + abort(make_response(({"message":f"Project with id: {project_id} not found"}, 404))) + + return project + +def get_course_of_project(project_id): + """This function returns the course_id of the course associated with the project: project_id""" + project = get_project(project_id) + return project.course_id + +def project_visible(project_id): + """Determine whether a project is visible for students""" + project = get_project(project_id) + return project.visible_for_students diff --git a/backend/project/utils/models/submission_utils.py b/backend/project/utils/models/submission_utils.py new file mode 100644 index 00000000..5cd46a68 --- /dev/null +++ b/backend/project/utils/models/submission_utils.py @@ -0,0 +1,33 @@ +"""This module contains helper functions related to submissions for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.submission import Submission +from project.utils.models.project_utils import get_course_of_project + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_submission(submission_id): + """Returns the submission associated with submission_id or the appropriate error""" + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message":"An error occurred while fetching the submission"}, 500))) + + if not submission: + abort(make_response(({"message":f"Submission with id: {submission_id} not found"}, 404))) + + return submission + +def get_course_of_submission(submission_id): + """Get the course linked to a given submission""" + submission = get_submission(submission_id) + return get_course_of_project(submission.project_id) diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py new file mode 100644 index 00000000..f601c8b3 --- /dev/null +++ b/backend/project/utils/models/user_utils.py @@ -0,0 +1,36 @@ +"""This module contains helper functions related to users for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.user import User + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_user(user_id): + """Returns the user associated with user_id or the appropriate error""" + try: + user = db.session.get(User, user_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user"} + , 500))) + if not user: + abort(make_response(({"message":f"User with id: {user_id} not found"}, 404))) + return user + +def is_teacher(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + user = get_user(auth_user_id) + return user.is_teacher + +def is_admin(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + user = get_user(auth_user_id) + return user.is_admin diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index d9f7d9cd..745006a1 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -232,4 +232,4 @@ def patch_by_id_from_model(model: DeclarativeMeta, "url": urljoin(f"{base_url}/", str(column_id))}), 200 except SQLAlchemyError: return {"error": "Something went wrong while updating the database.", - "url": base_url}, 500 \ No newline at end of file + "url": base_url}, 500 diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 2544968d..5d13b637 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -1,12 +1,10 @@ """Main entry point for the application.""" - from dotenv import load_dotenv from flask import Flask - -"""Index api point""" from flask import Blueprint, request from flask_restful import Resource, Api + index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) @@ -43,27 +41,30 @@ "id":"student02", "jobTitle":None }, + "admin1":{ + "id":"admin_person", + "jobTitle":"admin" + } } class Index(Resource): """Api endpoint for the / route""" def get(self): + "Returns the data associated with the authorization bearer token" auth = request.headers.get("Authorization") if not auth: return {"error":"Please give authorization"}, 401 - if auth in token_dict.keys(): + if token_dict.get(auth, None): return token_dict[auth], 200 return {"error":"Wrong address"}, 401 - -index_bp.add_url_rule("/", view_func=Index.as_view("index")) -if __name__ == "__main__": - load_dotenv() +index_bp.add_url_rule("/", view_func=Index.as_view("index")) - app = Flask(__name__) - app.register_blueprint(index_bp) +load_dotenv() - app.run(debug=True, host='0.0.0.0') +app = Flask(__name__) +app.register_blueprint(index_bp) +app.run(debug=True, host='0.0.0.0') diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7be87a8c..aebe7ce9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -153,4 +153,4 @@ def session(): # Truncate all tables for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) - session.commit() \ No newline at end of file + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 4466ee92..d3a32c9a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError from project.models.user import User from project.models.course import Course from project.models.course_share_code import CourseShareCode @@ -59,6 +60,27 @@ def valid_user_entry(session, valid_user): session.commit() return user +@pytest.fixture +def valid_admin(): + """ + Returns a valid admin user form + """ + return { + "uid": "admin_person", + "is_teacher": False, + "is_admin":True + } + +@pytest.fixture +def valid_admin_entry(session, valid_admin): + """ + Returns an admin user that is in the database + """ + user = User(**valid_admin) + session.add(user) + session.commit() + return user + @pytest.fixture def user_invalid_field(valid_user): """ @@ -177,9 +199,12 @@ def client(app): @pytest.fixture def valid_teacher_entry(session): """A valid teacher for testing that's already in the db""" - teacher = User(uid="Bart", is_teacher=True) - session.add(teacher) - session.commit() + teacher = User(uid="Bart", is_teacher=True, is_admin=False) + try: + session.add(teacher) + session.commit() + except SQLAlchemyError: + session.rollback() return teacher @pytest.fixture diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 3d5e199f..0249559a 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -15,7 +15,8 @@ def test_post_courses(self, client, valid_course): assert data["data"]["teacher"] == valid_course["teacher"] # Is reachable using the API - get_response = client.get(f"/courses/{data['data']['course_id']}", headers={"Authorization":"teacher2"}) + get_response = client.get(f"/courses/{data['data']['course_id']}", + headers={"Authorization":"teacher2"}) assert get_response.status_code == 200 @@ -58,7 +59,8 @@ def test_course_delete(self, valid_course_entry, client): assert response.status_code == 200 # Is not reachable using the API - get_response = client.get(f"/courses/{valid_course_entry.course_id}", headers={"Authorization":"teacher2"}) + get_response = client.get(f"/courses/{valid_course_entry.course_id}", + headers={"Authorization":"teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index f199ab06..2df488fa 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -9,22 +9,24 @@ class TestCourseShareLinks: and everyone should be able to list all students assigned to a course """ - def test_get_share_links(self, valid_course_entry, client): + def test_get_share_links(self, client, valid_course_entry): """Test whether the share links are accessible""" - response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", headers={"Authorization":"teacher2"}) + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", + headers={"Authorization":"teacher2"}) assert response.status_code == 200 - def test_post_share_links(self, valid_course_entry, client): + def test_post_share_links(self, client, valid_course_entry): """Test whether the share links are accessible to post to""" response = client.post( f"courses/{valid_course_entry.course_id}/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 201 - def test_delete_share_links(self, share_code_admin, client): + def test_delete_share_links(self, client, share_code_admin): """Test whether the share links are accessible to delete""" response = client.delete( - f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", headers={"Authorization":"teacher2"}) + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", + headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_get_share_links_404(self, client): @@ -34,10 +36,14 @@ def test_get_share_links_404(self, client): def test_post_share_links_404(self, client): """Test whether the share links are accessible to post to""" - response = client.post("courses/0/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) + response = client.post("courses/0/join_codes", + json={"for_admins": True}, + headers={"Authorization":"teacher2"}) assert response.status_code == 404 - def test_for_admins_required(self, valid_course_entry, client): + def test_for_admins_required(self, client, valid_course_entry): """Test whether the for_admins field is required""" - response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}, headers={"Authorization":"teacher2"}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", + json={}, + headers={"Authorization":"teacher2"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index fb9be82c..2cda69b6 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -16,7 +16,8 @@ def test_assignment_download(client, valid_project): ) assert response.status_code == 201 project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignments", headers={"Authorization":"teacher2"}) + response = client.get(f"/projects/{project_id}/assignments", + headers={"Authorization":"teacher2"}) # file downloaded succesfully assert response.status_code == 200 @@ -76,9 +77,7 @@ def test_remove_project(client, valid_project_entry): assert response.status_code == 404 def test_patch_project(client, valid_project_entry): - """ - Test functionality of the PATCH method for projects - """ + """Test functionality of the PATCH method for projects""" project_id = valid_project_entry.project_id diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 60fd971a..be528dae 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -19,7 +19,8 @@ def test_get_submissions_wrong_user(self, client: FlaskClient): def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" - response = client.get("/submissions?project_id=-1", headers={"Authorization":"teacher1"}) + response = client.get("/submissions?project_id=123456789", + headers={"Authorization":"teacher1"}) assert response.status_code == 404 # can't find course of project in authorization assert "message" in response.json @@ -31,7 +32,8 @@ def test_get_submissions_wrong_project_type(self, client: FlaskClient): def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", headers={"Authorization":"teacher2"}) + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", + headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert "message" in data @@ -51,7 +53,8 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.get(f"/submissions/{submission.submission_id}", headers={"Authorization":"ad3_teacher"}) + response = client.get(f"/submissions/{submission.submission_id}", + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" @@ -68,7 +71,8 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/0", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) + response = client.patch("/submissions/0", data={"grading": 20}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 assert data["message"] == "Submission with id: 0 not found" @@ -79,7 +83,9 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}, headers={"Authorization":"ad3_teacher"}) + response = client.patch(f"/submissions/{submission.submission_id}", + data={"grading": 100}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -90,7 +96,9 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}, headers={"Authorization":"ad3_teacher"}) + response = client.patch(f"/submissions/{submission.submission_id}", + data={"grading": "zero"}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -101,7 +109,9 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) + response = client.patch(f"/submissions/{submission.submission_id}", + data={"grading": 20}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" @@ -115,8 +125,6 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se "path": "/submissions/2", "status": False } - - # TODO test course admin (allowed) and student (not allowed) patch ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -132,7 +140,8 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.delete(f"submissions/{submission.submission_id}", headers={"Authorization":"student01"}) + response = client.delete(f"submissions/{submission.submission_id}", + headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 25b28d62..c6044db2 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -38,29 +38,34 @@ def user_db_session(): session.execute(table.delete()) session.commit() + class TestUserEndpoint: """Class to test user management endpoints.""" def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"student1"}) + response = client.delete(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"student1"}) assert response.status_code == 200 # If student 1 sends this request, he would get added again - get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) - + get_response = client.get(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"teacher1"}) + assert get_response.status_code == 404 - + def test_delete_user_not_yourself(self, client, valid_user_entry): """Test deleting a user that is not the user the authentication belongs to.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + response = client.delete(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"teacher1"}) assert response.status_code == 403 # If student 1 sends this request, he would get added again - get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) - + get_response = client.get(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"teacher1"}) + assert get_response.status_code == 200 def test_delete_not_present(self, client): @@ -75,7 +80,8 @@ def test_post_no_authentication(self, client, user_invalid_field): def test_post_authenticated(self, client, valid_user): """Test posting with wrong authentication.""" - response = client.post("/users", data=valid_user, headers={"Authorization":"teacher1"}) + response = client.post("/users", data=valid_user, + headers={"Authorization":"teacher1"}) assert response.status_code == 403 # POST to /users is not allowed def test_get_all_users(self, client, valid_user_entries): @@ -85,14 +91,13 @@ def test_get_all_users(self, client, valid_user_entries): # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) for valid_user in valid_user_entries: - assert valid_user.uid in \ - [user["uid"] for user in response.json["data"]] - + assert valid_user.uid in [user["uid"] for user in response.json["data"]] + def test_get_all_users_no_authentication(self, client): """Test getting all users without authentication.""" response = client.get("/users") assert response.status_code == 401 - + def test_get_all_users_wrong_authentication(self, client): """Test getting all users with wrong authentication.""" response = client.get("/users", headers={"Authorization":"wrong"}) @@ -103,18 +108,28 @@ def test_get_one_user(self, client, valid_user_entry): response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert "data" in response.json - + def test_get_one_user_no_authentication(self, client, valid_user_entry): """Test getting a single user without authentication.""" response = client.get(f"users/{valid_user_entry.uid}") assert response.status_code == 401 - + def test_get_one_user_wrong_authentication(self, client, valid_user_entry): """Test getting a single user with wrong authentication.""" response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) assert response.status_code == 401 - def test_patch_user(self, client, valid_user_entry): + def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_entry): + """Test trying to patch a user without authorization""" + new_is_teacher = not valid_user_entry.is_teacher + + response = client.patch(f"/users/{valid_user_entry.uid}", json={ + 'is_teacher': new_is_teacher, + 'is_admin': not valid_user_entry.is_admin + }, headers={"Authorization":"student01"}) + assert response.status_code == 403 # Patching a user is not allowed as a not-admin + + def test_patch_user(self, client, valid_admin_entry, valid_user_entry): """Test updating a user.""" new_is_teacher = not valid_user_entry.is_teacher @@ -122,28 +137,30 @@ def test_patch_user(self, client, valid_user_entry): response = client.patch(f"/users/{valid_user_entry.uid}", json={ 'is_teacher': new_is_teacher, 'is_admin': not valid_user_entry.is_admin - }) - assert response.status_code == 403 # Patching a user is never necessary and thus not allowed + }, headers={"Authorization":"admin1"}) + assert response.status_code == 200 - def test_patch_non_existent(self, client): + def test_patch_non_existent(self, client, valid_admin_entry): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ 'is_teacher': False, 'is_admin': True - }) - assert response.status_code == 403 # Patching is not allowed + }, headers={"Authorization":"admin1"}) + assert response.status_code == 404 - def test_patch_non_json(self, client, valid_user_entry): + def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] - response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) - assert response.status_code == 403 # Patching is not allowed + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, + headers={"Authorization":"admin1"}) + assert response.status_code == 415 def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false", headers={"Authorization":"teacher1"}) + response = client.get("/users?is_admin=true&is_teacher=false", + headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query From d247551b1792b69bedf93fcd1c671c04d8a3705b Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:21:07 +0100 Subject: [PATCH 132/144] Enhancement/debug env variable (#128) * added waitress te requirements to serve in production * added debug as an env variable and serving with waitress when not in debug mode --- backend/project/__main__.py | 12 ++++++++++-- backend/requirements.txt | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 444d1410..8a965261 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,10 +1,18 @@ """Main entry point for the application.""" +from os import getenv from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url +load_dotenv() +DEBUG=getenv("DEBUG") + if __name__ == "__main__": - load_dotenv() app = create_app_with_db(url) - app.run(debug=True, host='0.0.0.0') + + if DEBUG and DEBUG.lower() == "true": + app.run(debug=True, host='0.0.0.0') + else: + from waitress import serve + serve(app, host='0.0.0.0', port=5000) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9e9dc90a..a5d3cd59 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,5 @@ python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 SQLAlchemy~=2.0.27 -requests~=2.25.1 \ No newline at end of file +requests~=2.25.1 +waitress From 5283f0c2711bee8825c242d8315d9acaaec42e29 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:27:09 +0100 Subject: [PATCH 133/144] fixed backend to frontend typo and added workflow branch argument (#119) * fixed backend to frontend typo and added workflow branch argument * added back 2. back to readme of backend --- backend/README.md | 4 ++-- frontend/README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/README.md b/backend/README.md index b7cd5ee4..d978ac22 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Project pigeonhole backend -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) ## Prerequisites **1. Clone the repo** ```sh diff --git a/frontend/README.md b/frontend/README.md index 5f81d16b..6d217e0b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ -# Project pigeonhole backend -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) +# Project pigeonhole frontend +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) ## Prerequisites **1. Clone the repo** ```sh From 3d169f88970f7ebd33df6f485721d734aa1c3fd1 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:36:19 +0100 Subject: [PATCH 134/144] changed submission status to enum (#133) * changed submission status to enum * resolved linting --- backend/db_construct.sql | 4 +++- backend/project/endpoints/submissions.py | 4 ++-- backend/project/models/submission.py | 21 +++++++++++++++++++-- backend/tests.yaml | 2 +- backend/tests/conftest.py | 8 ++++---- backend/tests/endpoints/conftest.py | 4 ++-- backend/tests/endpoints/submissions_test.py | 4 ++-- backend/tests/models/submission_test.py | 4 ++-- 8 files changed, 35 insertions(+), 16 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index a1ad51fe..3713788d 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,3 +1,5 @@ +CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); + CREATE TABLE users ( uid VARCHAR(255), is_teacher BOOLEAN, @@ -58,7 +60,7 @@ CREATE TABLE submissions ( grading INTEGER CHECK (grading >= 0 AND grading <= 20), submission_time TIMESTAMP WITH TIME ZONE NOT NULL, submission_path VARCHAR(50) NOT NULL, - submission_status BOOLEAN NOT NULL, + submission_status submission_status NOT NULL, PRIMARY KEY(submission_id), CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) ON DELETE CASCADE diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index a12aa578..48403501 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -8,7 +8,7 @@ from flask_restful import Resource from sqlalchemy import exc from project.db_in import db -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus from project.models.project import Project from project.models.user import User from project.utils.files import filter_files, all_files_uploaded, zip_files @@ -121,7 +121,7 @@ def post(self) -> dict[str, any]: zip_file.save(path.join(f"{UPLOAD_FOLDER}/", submission.submission_path)) # Submission status - submission.submission_status = False + submission.submission_status = SubmissionStatus.RUNNING session.add(submission) session.commit() diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index cda2620d..8768dd99 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -1,9 +1,24 @@ """Submission model""" from dataclasses import dataclass -from sqlalchemy import Column, String, ForeignKey, Integer, CheckConstraint, DateTime, Boolean +from enum import Enum +from sqlalchemy import ( + Column, + String, + ForeignKey, + Integer, + CheckConstraint, + DateTime, + Enum as EnumField) from project.db_in import db +class SubmissionStatus(str, Enum): + """Enum for submission status""" + SUCCESS = 'SUCCESS' + LATE = 'LATE' + FAIL = 'FAIL' + RUNNING = 'RUNNING' + @dataclass class Submission(db.Model): """This class describes the submissions table, @@ -23,4 +38,6 @@ class Submission(db.Model): grading: int = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) submission_time: DateTime = Column(DateTime(timezone=True), nullable=False) submission_path: str = Column(String(50), nullable=False) - submission_status: bool = Column(Boolean, nullable=False) + submission_status: SubmissionStatus = Column( + EnumField(SubmissionStatus, name="submission_status"), + nullable=False) diff --git a/backend/tests.yaml b/backend/tests.yaml index d1a41efb..6238d2ec 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -18,7 +18,7 @@ services: auth-server: build: context: . - dockerfile: ./Dockerfile_auth_test + dockerfile: Dockerfile_auth_test environment: API_HOST: http://auth-server volumes: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aebe7ce9..cc605602 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,7 +9,7 @@ from project.models.user import User from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus @pytest.fixture def db_session(): @@ -104,14 +104,14 @@ def submissions(session): grading=16, submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/1", - submission_status=True + submission_status= SubmissionStatus.SUCCESS ), Submission( uid="student02", project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/2", - submission_status=False + submission_status= SubmissionStatus.FAIL ), Submission( uid="student02", @@ -119,7 +119,7 @@ def submissions(session): grading=15, submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/3", - submission_status=True + submission_status= SubmissionStatus.SUCCESS ) ] diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index d3a32c9a..3f234b62 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -12,7 +12,7 @@ from project.models.course_share_code import CourseShareCode from project import create_app_with_db from project.db_in import url, db -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus from project.models.project import Project @@ -27,7 +27,7 @@ def valid_submission(valid_user_entry, valid_project_entry): "grading": 16, "submission_time": datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), "submission_path": "/submission/1", - "submission_status": True + "submission_status": SubmissionStatus.SUCCESS } @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index be528dae..9ef6392f 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -65,7 +65,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): "grading": 16, "time": "Thu, 14 Mar 2024 12:00:00 GMT", "path": "/submissions/1", - "status": True + "status": 'SUCCESS' } ### PATCH SUBMISSION ### @@ -123,7 +123,7 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se "grading": 20, "time": 'Thu, 14 Mar 2024 23:59:59 GMT', "path": "/submissions/2", - "status": False + "status": 'FAIL' } ### DELETE SUBMISSION ### diff --git a/backend/tests/models/submission_test.py b/backend/tests/models/submission_test.py index 66a2779b..28918c5e 100644 --- a/backend/tests/models/submission_test.py +++ b/backend/tests/models/submission_test.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from project.models.project import Project -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus class TestSubmissionModel: """Class to test the Submission model""" @@ -18,7 +18,7 @@ def test_create_submission(self, session: Session): project_id=project.project_id, submission_time=datetime(2023,3,15,13,0,0), submission_path="/submissions", - submission_status=True + submission_status=SubmissionStatus.SUCCESS ) session.add(submission) session.commit() From cdb2dbaabde2c6bd7397aa28cf6d6be93f3fe9a2 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:57:33 +0100 Subject: [PATCH 135/144] Fix #77 changed grading to float (#134) --- backend/db_construct.sql | 2 +- backend/project/endpoints/submissions.py | 12 +++++++++--- backend/project/models/submission.py | 3 ++- backend/tests/endpoints/submissions_test.py | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index 3713788d..bb7c7eb7 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -57,7 +57,7 @@ CREATE TABLE submissions ( submission_id INT GENERATED ALWAYS AS IDENTITY, uid VARCHAR(255) NOT NULL, project_id INT NOT NULL, - grading INTEGER CHECK (grading >= 0 AND grading <= 20), + grading FLOAT CHECK (grading >= 0 AND grading <= 20), submission_time TIMESTAMP WITH TIME ZONE NOT NULL, submission_path VARCHAR(50) NOT NULL, submission_status submission_status NOT NULL, diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 48403501..ebc22a88 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -212,10 +212,16 @@ def patch(self, submission_id:int) -> dict[str, any]: # Update the grading field grading = request.form.get("grading") if grading is not None: - if not (grading.isdigit() and 0 <= int(grading) <= 20): - data["message"] = "Invalid grading (grading=0-20)" + try: + grading_float = float(grading) + if 0 <= grading_float <= 20: + submission.grading = grading_float + else: + data["message"] = "Invalid grading (grading=0-20)" + return data, 400 + except ValueError: + data["message"] = "Invalid grading (not a valid float)" return data, 400 - submission.grading = int(grading) # Save the submission session.commit() diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index 8768dd99..1587f80f 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -9,6 +9,7 @@ Integer, CheckConstraint, DateTime, + Float, Enum as EnumField) from project.db_in import db @@ -35,7 +36,7 @@ class Submission(db.Model): submission_id: int = Column(Integer, primary_key=True) uid: str = Column(String(255), ForeignKey("users.uid"), nullable=False) project_id: int = Column(Integer, ForeignKey("projects.project_id"), nullable=False) - grading: int = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) + grading: float = Column(Float, CheckConstraint("grading >= 0 AND grading <= 20")) submission_time: DateTime = Column(DateTime(timezone=True), nullable=False) submission_path: str = Column(String(50), nullable=False) submission_status: SubmissionStatus = Column( diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 9ef6392f..a900bb84 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -101,7 +101,7 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" + assert data["message"] == "Invalid grading (not a valid float)" def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): """Test patching a submission""" From 8b4b093c7e112e41ef6393e901bd0905c3e24e36 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 15:46:44 +0100 Subject: [PATCH 136/144] Fixed #112 (#113) * Fixed #112 * removed new line --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index a5d3cd59..1bbc2e9e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,5 +5,5 @@ python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 SQLAlchemy~=2.0.27 -requests~=2.25.1 +requests>=2.31.0 waitress From 2d0574c0250c39adba423e7615a9e2e4e924d2b8 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 21 Mar 2024 16:16:35 +0100 Subject: [PATCH 137/144] Fix for uploading project files (#120) * files are now patchable uncluding the files * fix for uploading a file that is not a zip that it doesn't get saved * unsaved changes * buzoghany requested changes * linter * added rollbacks * extra rollback --- .../endpoints/projects/project_detail.py | 55 +++++++++++++++++-- .../project/endpoints/projects/projects.py | 38 +++++++------ 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index c9d9dc03..060587c7 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -3,20 +3,27 @@ for example /projects/1 if the project id of the corresponding project is 1 """ -from os import getenv +import os +import zipfile from urllib.parse import urljoin from flask import request from flask_restful import Resource +from project.db_in import db + from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model from project.utils.authentication import authorize_teacher_or_project_admin, \ authorize_teacher_of_project, authorize_project_visible -API_URL = getenv('API_HOST') +from project.endpoints.projects.endpoint_parser import parse_project_params + +API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + class ProjectDetail(Resource): """ @@ -45,14 +52,54 @@ def patch(self, project_id): Update method for updating a specific project filtered by id of that specific project """ + project_json = parse_project_params() - return patch_by_id_from_model( + output, status_code = patch_by_id_from_model( Project, "project_id", project_id, RESPONSE_URL, - request.json + project_json ) + if status_code != 200: + return output, status_code + + if "assignment_file" in request.files: + file = request.files["assignment_file"] + filename = os.path.basename(file.filename) + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{project_id}") + os.makedirs(project_upload_directory, exist_ok=True) + try: + # remove the old file + try: + to_rem_files = os.listdir(project_upload_directory) + for to_rem_file in to_rem_files: + to_rem_file_path = os.path.join(project_upload_directory, to_rem_file) + if os.path.isfile(to_rem_file_path): + os.remove(to_rem_file_path) + except FileNotFoundError: + db.session.rollback() + return ({ + "message": "Something went wrong deleting the old project files", + "url": f"{API_URL}/projects/{project_id}" + }) + + # removed all files now upload the new files + file.save(os.path.join(project_upload_directory, filename)) + zip_location = os.path.join(project_upload_directory, filename) + with zipfile.ZipFile(zip_location) as upload_zip: + upload_zip.extractall(project_upload_directory) + project_json["assignment_file"] = filename + except zipfile.BadZipfile: + db.session.rollback() + return ({ + "message": + "Please provide a valid .zip file for updating the instructions", + "url": f"{API_URL}/projects/{project_id}" + }, + 400) + + return output, status_code @authorize_teacher_of_project def delete(self, project_id): diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ccbdca70..b0afa4f8 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -9,6 +9,8 @@ from flask import request, jsonify from flask_restful import Resource +from project.db_in import db + from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance from project.utils.authentication import authorize_teacher @@ -18,6 +20,7 @@ API_URL = os.getenv('API_HOST') UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + class ProjectsEndpoint(Resource): """ Class for projects endpoints @@ -47,10 +50,12 @@ def post(self, teacher_id=None): using flask_restfull parse lib """ - file = request.files["assignment_file"] project_json = parse_project_params() - filename = os.path.basename(file.filename) - project_json["assignment_file"] = filename + filename = None + if "assignment_file" in request.files: + file = request.files["assignment_file"] + filename = os.path.basename(file.filename) + project_json["assignment_file"] = filename # save the file that is given with the request try: @@ -73,20 +78,21 @@ def post(self, teacher_id=None): return new_project, status_code project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") - os.makedirs(project_upload_directory, exist_ok=True) - - file.save(os.path.join(project_upload_directory, filename)) - try: - with zipfile.ZipFile(os.path.join(project_upload_directory, filename)) as upload_zip: - upload_zip.extractall(project_upload_directory) - except zipfile.BadZipfile: - return ({ - "message": "Please provide a .zip file for uploading the instructions", - "url": f"{API_URL}/projects" - }, - 400) - + if filename is not None: + try: + file.save(os.path.join(project_upload_directory, filename)) + zip_location = os.path.join(project_upload_directory, filename) + with zipfile.ZipFile(zip_location) as upload_zip: + upload_zip.extractall(project_upload_directory) + except zipfile.BadZipfile: + os.remove(os.path.join(project_upload_directory, filename)) + db.session.rollback() + return ({ + "message": "Please provide a .zip file for uploading the instructions", + "url": f"{API_URL}/projects" + }, + 400) return { "message": "Project created succesfully", "data": new_project, From bcee3bcb74715541e17b18bf9713e4dd38abeede Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sun, 24 Mar 2024 17:33:29 +0100 Subject: [PATCH 138/144] Removes all usage of str(e) in courses endpoint (#126) * Removed all usage of str(e) in courses endpoint * common parts of error put into const var --------- Co-authored-by: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> --- .../endpoints/courses/courses_utils.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index cb36c6a4..4c01ee73 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -17,7 +17,8 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(f"{API_URL}/", "courses") +RESPONSE_URL = urljoin(API_URL + "/", "courses") +BASE_DB_ERROR = "Database error occurred while" def execute_query_abort_if_db_error(query, url, query_all=False): """ @@ -35,8 +36,8 @@ def execute_query_abort_if_db_error(query, url, query_all=False): result = query.all() else: result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) + except SQLAlchemyError: + response = json_message(f"{BASE_DB_ERROR} executing query") response["url"] = url abort(500, description=response) return result @@ -52,9 +53,9 @@ def add_abort_if_error(to_add, url): """ try: db.session.add(to_add) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} adding object") response["url"] = url abort(500, description=response) @@ -69,9 +70,9 @@ def delete_abort_if_error(to_delete, url): """ try: db.session.delete(to_delete) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} deleting object") response["url"] = url abort(500, description=response) @@ -82,9 +83,9 @@ def commit_abort_if_error(url): """ try: db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} committing changes") response["url"] = url abort(500, description=response) From e770091c8164c1f3ec7726ddc09e5736622255cc Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:48:11 +0100 Subject: [PATCH 139/144] changed port to 5001 (#142) --- backend/test_auth_server/__main__.py | 2 +- backend/tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 5d13b637..bf5fd576 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -67,4 +67,4 @@ def get(self): app = Flask(__name__) app.register_blueprint(index_bp) -app.run(debug=True, host='0.0.0.0') +app.run(debug=True, host='0.0.0.0', port=5001) diff --git a/backend/tests.yaml b/backend/tests.yaml index 6238d2ec..397702d8 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -41,7 +41,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose + AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments volumes: - .:/app From 71eb3d47b09ed3d122f2f9fe970fc0073120b5b3 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:51:56 +0100 Subject: [PATCH 140/144] Removed is_teacher and is_admin, changed to role enum (#127) * Removed is_teacher and is_admin, changed to role enum * sql enum type * usage of enum and fixed sql * made role serializable * clean * docs * fixed user patch * args vs json * test --- backend/db_construct.sql | 5 +- .../endpoints/index/OpenAPI_Object.json | 36 +++----- backend/project/endpoints/users.py | 45 +++++----- backend/project/models/user.py | 23 +++-- backend/project/utils/authentication.py | 87 +++++++++++-------- backend/project/utils/models/user_utils.py | 6 +- backend/tests/conftest.py | 10 +-- backend/tests/endpoints/conftest.py | 21 +++-- backend/tests/endpoints/user_test.py | 53 ++++++----- backend/tests/models/user_test.py | 10 +-- 10 files changed, 158 insertions(+), 138 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index bb7c7eb7..e3f6af41 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,9 +1,10 @@ +CREATE TYPE role AS ENUM ('STUDENT', 'TEACHER', 'ADMIN'); + CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); CREATE TABLE users ( uid VARCHAR(255), - is_teacher BOOLEAN, - is_admin BOOLEAN, + role role NOT NULL, PRIMARY KEY(uid) ); diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 829f7c38..5ac3ef53 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1374,14 +1374,11 @@ "uid": { "type": "string" }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "enum" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": ["uid", "role"] } } } @@ -1399,14 +1396,11 @@ "uid": { "type": "string" }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "enum" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": ["uid", "role"] } } } @@ -1451,14 +1445,11 @@ "uid": { "type": "string" }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "enum" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": ["uid", "role"] } } } @@ -1487,14 +1478,11 @@ "schema": { "type": "object", "properties": { - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "role" } }, - "required": ["is_teacher", "is_admin"] + "required": ["role"] } } } diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 7d073c6c..34e65817 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.user import User as userModel +from project.models.user import User as userModel, Role from project.utils.authentication import login_required, authorize_user, \ authorize_admin, not_allowed @@ -29,16 +29,13 @@ def get(self): """ try: query = userModel.query - is_teacher = request.args.get('is_teacher') - is_admin = request.args.get('is_admin') - - if is_teacher is not None: - query = query.filter(userModel.is_teacher == (is_teacher.lower() == 'true')) - - if is_admin is not None: - query = query.filter(userModel.is_admin == (is_admin.lower() == 'true')) + role = request.args.get("role") + if role is not None: + role = Role[role.upper()] + query = query.filter(userModel.role == role) users = query.all() + users = [user.to_dict() for user in users] result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users", "status_code": 200}) @@ -54,26 +51,25 @@ def post(self): It should create a new user and return a success message. """ uid = request.json.get('uid') - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') + role = request.json.get("role") + role = Role[role.upper()] if role is not None else None url = f"{API_URL}/users" - if is_teacher is None or is_admin is None or uid is None: + if role is None or uid is None: return { "message": "Invalid request data!", "correct_format": { "uid": "User ID (string)", - "is_teacher": "Teacher status (boolean)", - "is_admin": "Admin status (boolean)" - },"url": url + "role": "User role (string)" + },"url": f"{API_URL}/users" }, 400 try: user = db.session.get(userModel, uid) if user is not None: # Bad request, error code could be 409 but is rarely used return {"message": f"User {uid} already exists"}, 400 - # Code to create a new user in the database using the uid, is_teacher, and is_admin - new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) + # Code to create a new user in the database using the uid and role + new_user = userModel(uid=uid, role=role) db.session.add(new_user) db.session.commit() return jsonify({"message": "User created successfully!", @@ -99,7 +95,7 @@ def get(self, user_id): if user is None: return {"message": "User not found!","url": f"{API_URL}/users"}, 404 - return jsonify({"message": "User queried","data":user, + return jsonify({"message": "User queried","data":user.to_dict(), "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: return {"message": "An error occurred while fetching the user", @@ -114,22 +110,21 @@ def patch(self, user_id): dict: A dictionary containing the message indicating the success or failure of the update. """ - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') + role = request.json.get("role") + role = Role[role.upper()] if role is not None else None try: user = db.session.get(userModel, user_id) if user is None: return {"message": "User not found!","url": f"{API_URL}/users"}, 404 - if is_teacher is not None: - user.is_teacher = is_teacher - if is_admin is not None: - user.is_admin = is_admin + if role is not None: + user.role = role # Save the changes to the database db.session.commit() return jsonify({"message": "User updated successfully!", - "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) + "data": user.to_dict(), + "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() diff --git a/backend/project/models/user.py b/backend/project/models/user.py index bb130349..7cd59fd1 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -1,17 +1,30 @@ """User model""" +from enum import Enum from dataclasses import dataclass -from sqlalchemy import Boolean, Column, String +from sqlalchemy import Column, String, Enum as EnumField from project.db_in import db +class Role(Enum): + """This class defines the roles of a user""" + STUDENT = 0 + TEACHER = 1 + ADMIN = 2 + @dataclass class User(db.Model): """This class defines the users table, - a user has a uid, - is_teacher and is_admin booleans because a user + a user has a uid and a role, a user can be either a student,admin or teacher""" __tablename__ = "users" uid: str = Column(String(255), primary_key=True) - is_teacher: bool = Column(Boolean) - is_admin: bool = Column(Boolean) + role: Role = Column(EnumField(Role), nullable=False) + def to_dict(self): + """ + Converts a User to a serializable dict + """ + return { + 'uid': self.uid, + 'role': self.role.name, # Convert the enum to a string + } diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 61b64e61..c1a96248 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -13,7 +13,7 @@ from project import db -from project.models.user import User +from project.models.user import User, Role from project.utils.models.course_utils import is_admin_of_course, \ is_student_of_course, is_teacher_of_course from project.utils.models.project_utils import get_course_of_project, project_visible @@ -29,7 +29,7 @@ def not_allowed(f): """Decorator function to immediately abort the current request and return 403: Forbidden""" @wraps(f) def wrap(*args, **kwargs): - return {"message":"Forbidden action"}, 403 + return {"message": "Forbidden action"}, 403 return wrap @@ -39,20 +39,23 @@ def return_authenticated_user_id(): """ authentication = request.headers.get("Authorization") if not authentication: - abort(make_response(({"message": - "No authorization given, you need an access token to use this API"} - , 401))) + abort( + make_response(( + {"message": + "No authorization given, you need an access token to use this API"}, + 401))) auth_header = {"Authorization": authentication} try: - response = requests.get(AUTHENTICATION_URL, headers=auth_header, timeout=5) + response = requests.get( + AUTHENTICATION_URL, headers=auth_header, timeout=5) except TimeoutError: - abort(make_response(({"message":"Request to Microsoft timed out"} - , 500))) + abort(make_response( + ({"message": "Request to Microsoft timed out"}, 500))) if not response or response.status_code != 200: abort(make_response(({"message": - "An error occured while trying to authenticate your access token"} - , 401))) + "An error occured while trying to authenticate your access token"}, + 401))) user_info = response.json() auth_user_id = user_info["id"] @@ -61,26 +64,30 @@ def return_authenticated_user_id(): except SQLAlchemyError: db.session.rollback() abort(make_response(({"message": - "An unexpected database error occured while fetching the user"}, 500))) + "An unexpected database error occured while fetching the user"}, + 500))) if user: return auth_user_id - is_teacher = False + + # Use the Enum here + role = Role.STUDENT if user_info["jobTitle"] is not None: - is_teacher = True + role = Role.TEACHER # add user if not yet in database try: - new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) + new_user = User(uid=auth_user_id, role=role) db.session.add(new_user) db.session.commit() except SQLAlchemyError: db.session.rollback() abort(make_response(({"message": - """An unexpected database error occured + """An unexpected database error occured while creating the user during authentication"""}, 500))) return auth_user_id + def login_required(f): """ This function will check if the person sending a request to the API is logged in @@ -104,7 +111,7 @@ def wrap(*args, **kwargs): if is_admin(auth_user_id): return f(*args, **kwargs) abort(make_response(({"message": - """You are not authorized to perfom this action, + """You are not authorized to perfom this action, only admins are authorized"""}, 403))) return wrap @@ -121,7 +128,7 @@ def wrap(*args, **kwargs): kwargs["teacher_id"] = auth_user_id return f(*args, **kwargs) abort(make_response(({"message": - """You are not authorized to perfom this action, + """You are not authorized to perfom this action, only teachers are authorized"""}, 403))) return wrap @@ -138,7 +145,8 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, kwargs["course_id"]): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -153,10 +161,10 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = kwargs["course_id"] if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, only teachers and course admins are authorized"""}, 403))) return wrap @@ -174,7 +182,7 @@ def wrap(*args, **kwargs): if auth_user_id == user_id: return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not this user"""}, 403))) return wrap @@ -194,7 +202,7 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, course_id): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not the teacher of this project"""}, 403))) return wrap @@ -211,9 +219,9 @@ def wrap(*args, **kwargs): project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not the teacher or an admin of this project"""}, 403))) return wrap @@ -232,13 +240,15 @@ def wrap(*args, **kwargs): project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap + def authorize_submissions_request(f): """This function will check if the person sending a request to the API is logged in, and either the teacher/admin of the course or the student @@ -251,14 +261,15 @@ def wrap(*args, **kwargs): course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id) - and auth_user_id == request.args.get("uid")): + and auth_user_id == request.args.get("uid")): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -273,9 +284,10 @@ def wrap(*args, **kwargs): course_id = get_course_of_project(project_id) if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id) - and auth_user_id == request.form.get("uid")): + and auth_user_id == request.form.get("uid")): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -290,7 +302,8 @@ def wrap(*args, **kwargs): submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -303,9 +316,10 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = get_course_of_submission(kwargs["submission_id"]) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -323,9 +337,8 @@ def wrap(*args, **kwargs): return f(*args, **kwargs) course_id = get_course_of_project(submission.project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) abort(make_response(({"message": - "You're not authorized to perform this action"} - , 403))) + "You're not authorized to perform this action"}, 403))) return wrap diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py index f601c8b3..37cd263c 100644 --- a/backend/project/utils/models/user_utils.py +++ b/backend/project/utils/models/user_utils.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.user import User +from project.models.user import User, Role load_dotenv() API_URL = getenv("API_HOST") @@ -28,9 +28,9 @@ def get_user(user_id): def is_teacher(auth_user_id): """This function checks whether the user with auth_user_id is a teacher""" user = get_user(auth_user_id) - return user.is_teacher + return user.role == Role.TEACHER def is_admin(auth_user_id): """This function checks whether the user with auth_user_id is a teacher""" user = get_user(auth_user_id) - return user.is_admin + return user.role == Role.ADMIN diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc605602..fe9d3961 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,7 +6,7 @@ from project.sessionmaker import engine, Session from project.db_in import db from project.models.course import Course -from project.models.user import User +from project.models.user import User,Role from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus @@ -34,10 +34,10 @@ def db_session(): def users(): """Return a list of users to populate the database""" return [ - User(uid="brinkmann", is_admin=True, is_teacher=True), - User(uid="laermans", is_admin=True, is_teacher=True), - User(uid="student01", is_admin=False, is_teacher=False), - User(uid="student02", is_admin=False, is_teacher=False) + User(uid="brinkmann", role=Role.ADMIN), + User(uid="laermans", role=Role.ADMIN), + User(uid="student01", role=Role.STUDENT), + User(uid="student02", role=Role.STUDENT) ] def courses(): diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 3f234b62..de74e6ab 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -7,7 +7,7 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.exc import SQLAlchemyError -from project.models.user import User +from project.models.user import User,Role from project.models.course import Course from project.models.course_share_code import CourseShareCode from project import create_app_with_db @@ -47,7 +47,7 @@ def valid_user(): """ return { "uid": "w_student", - "is_teacher": False + "role": Role.STUDENT.name } @pytest.fixture @@ -67,8 +67,7 @@ def valid_admin(): """ return { "uid": "admin_person", - "is_teacher": False, - "is_admin":True + "role": Role.ADMIN, } @pytest.fixture @@ -95,10 +94,10 @@ def valid_user_entries(session): Returns a list of users that are in the database """ users = [ - User(uid="del", is_admin=False, is_teacher=True), - User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True), - User(uid="query_user", is_admin=True, is_teacher=False)] + User(uid="del", role=Role.TEACHER), + User(uid="pat", role=Role.TEACHER), + User(uid="u_get", role=Role.TEACHER), + User(uid="query_user", role=Role.ADMIN)] session.add_all(users) session.commit() @@ -149,7 +148,7 @@ def app(): @pytest.fixture def course_teacher_ad(): """A user that's a teacher for testing""" - ad_teacher = User(uid="Gunnar", is_teacher=True, is_admin=True) + ad_teacher = User(uid="Gunnar", role=Role.TEACHER) return ad_teacher @pytest.fixture @@ -199,7 +198,7 @@ def client(app): @pytest.fixture def valid_teacher_entry(session): """A valid teacher for testing that's already in the db""" - teacher = User(uid="Bart", is_teacher=True, is_admin=False) + teacher = User(uid="Bart", role=Role.TEACHER) try: session.add(teacher) session.commit() @@ -229,7 +228,7 @@ def valid_course_entry(session, valid_course): def valid_students_entries(session): """Valid students for testing that are already in the db""" students = [ - User(uid=f"student_sel2_{i}", is_teacher=False) + User(uid=f"student_sel2_{i}", role=Role.STUDENT) for i in range(3) ] session.add_all(students) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c6044db2..7d3a0c39 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -11,7 +11,7 @@ import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine -from project.models.user import User +from project.models.user import User,Role from project.db_in import db from tests import db_url @@ -24,12 +24,12 @@ def user_db_session(): db.metadata.create_all(engine) session = Session() session.add_all( - [User(uid="del", is_admin=False, is_teacher=True), - User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True), - User(uid="query_user", is_admin=True, is_teacher=False) - ] - ) + [User(uid="del", role=Role.TEACHER), + User(uid="pat", role=Role.TEACHER), + User(uid="u_get", role=Role.TEACHER), + User(uid="query_user", role=Role.ADMIN) + ] + ) session.commit() yield session session.rollback() @@ -120,38 +120,50 @@ def test_get_one_user_wrong_authentication(self, client, valid_user_entry): assert response.status_code == 401 def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_entry): - """Test trying to patch a user without authorization""" - new_is_teacher = not valid_user_entry.is_teacher + """Test updating a user.""" + if valid_user_entry.role == Role.TEACHER: + new_role = Role.ADMIN + if valid_user_entry.role == Role.ADMIN: + new_role = Role.STUDENT + else: + new_role = Role.TEACHER + new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ - 'is_teacher': new_is_teacher, - 'is_admin': not valid_user_entry.is_admin + 'role': new_role }, headers={"Authorization":"student01"}) assert response.status_code == 403 # Patching a user is not allowed as a not-admin def test_patch_user(self, client, valid_admin_entry, valid_user_entry): """Test updating a user.""" - new_is_teacher = not valid_user_entry.is_teacher - + if valid_user_entry.role == Role.TEACHER: + new_role = Role.ADMIN + if valid_user_entry.role == Role.ADMIN: + new_role = Role.STUDENT + else: + new_role = Role.TEACHER + new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ - 'is_teacher': new_is_teacher, - 'is_admin': not valid_user_entry.is_admin + 'role': new_role }, headers={"Authorization":"admin1"}) assert response.status_code == 200 def test_patch_non_existent(self, client, valid_admin_entry): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ - 'is_teacher': False, - 'is_admin': True + 'role': Role.TEACHER.name }, headers={"Authorization":"admin1"}) assert response.status_code == 404 def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) - valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] + if valid_user_form["role"] == Role.TEACHER.name: + valid_user_form["role"] = Role.STUDENT.name + else: + valid_user_form["role"] = Role.TEACHER.name + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, headers={"Authorization":"admin1"}) assert response.status_code == 415 @@ -159,12 +171,11 @@ def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false", + response = client.get("/users?role=ADMIN", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query users = response.json["data"] for user in users: - assert user["is_admin"] is True - assert user["is_teacher"] is False + assert Role[user["role"]] == Role.ADMIN diff --git a/backend/tests/models/user_test.py b/backend/tests/models/user_test.py index 8a026711..05520b8c 100644 --- a/backend/tests/models/user_test.py +++ b/backend/tests/models/user_test.py @@ -3,14 +3,14 @@ from pytest import raises, mark from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from project.models.user import User +from project.models.user import User,Role class TestUserModel: """Class to test the User model""" def test_create_user(self, session: Session): """Test if a user can be created""" - user = User(uid="user01", is_teacher=False, is_admin=False) + user = User(uid="user01", role=Role.STUDENT) session.add(user) session.commit() assert session.get(User, "user01") is not None @@ -21,14 +21,14 @@ def test_query_user(self, session: Session): assert session.query(User).count() == 4 teacher = session.query(User).filter_by(uid="brinkmann").first() assert teacher is not None - assert teacher.is_teacher + assert teacher.role == Role.ADMIN def test_update_user(self, session: Session): """Test if a user can be updated""" student = session.query(User).filter_by(uid="student01").first() - student.is_admin = True + student.role = Role.ADMIN session.commit() - assert session.get(User, "student01").is_admin + assert session.get(User, "student01").role == Role.ADMIN def test_delete_user(self, session: Session): """Test if a user can be deleted""" From 7c897c115e69348ff78d6c9e51599fb8c918fcd2 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:52:40 +0100 Subject: [PATCH 141/144] check if course name is blank (#100) * check if course name is blank * generalized blank string check * fixed yo request * fixed conflicts * cleaned code * fix --- backend/project/utils/query_agent.py | 2 +- backend/tests/endpoints/conftest.py | 10 ++++++ .../tests/endpoints/course/courses_test.py | 34 ++++++++++++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..01368eb3 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -61,7 +61,7 @@ def create_model_instance(model: DeclarativeMeta, if required_fields is None: required_fields = [] # Check if all non-nullable fields are present in the data - missing_fields = [field for field in required_fields if field not in data] + missing_fields = [field for field in required_fields if field not in data or data[field] == ''] if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index de74e6ab..8a1c53ab 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -216,6 +216,16 @@ def course_no_name(valid_teacher_entry): """A course with no name""" return {"name": "", "teacher": valid_teacher_entry.uid} +@pytest.fixture +def course_empty_name(): + """A course with an empty name""" + return {"name": "", "teacher": "Bart"} + +@pytest.fixture +def invalid_course(): + """An invalid course for testing.""" + return {"invalid": "error"} + @pytest.fixture def valid_course_entry(session, valid_course): """A valid course for testing that's already in the db""" diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 0249559a..b82d2728 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,14 +1,16 @@ """Here we will test all the courses endpoint related functionality""" + class TestCourseEndpoint: """Class for testing the courses endpoint""" - def test_post_courses(self, client, valid_course): + def test_post_courses(self, client, valid_course, invalid_course): """ Test posting a course to the /courses endpoint """ - response = client.post("/courses", json=valid_course, headers={"Authorization":"teacher2"}) + response = client.post("/courses", json=valid_course, + headers={"Authorization": "teacher2"}) assert response.status_code == 201 data = response.json assert data["data"]["name"] == "Sel" @@ -16,9 +18,23 @@ def test_post_courses(self, client, valid_course): # Is reachable using the API get_response = client.get(f"/courses/{data['data']['course_id']}", - headers={"Authorization":"teacher2"}) + headers={"Authorization": "teacher2"}) assert get_response.status_code == 200 + response = client.post( + "/courses?uid=Bart", json=invalid_course, + headers={"Authorization": "teacher2"} + ) # invalid course + assert response.status_code == 400 + + def test_post_no_name(self, client, course_empty_name): + """ + Test posting a course with a blank name + """ + + response = client.post("/courses?uid=Bart", json=course_empty_name, + headers={"Authorization": "teacher2"}) + assert response.status_code == 400 def test_post_courses_course_id_students_and_admins( self, client, valid_course_entry, valid_students_entries): @@ -33,18 +49,18 @@ def test_post_courses_course_id_students_and_admins( response = client.post( sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, headers={"Authorization":"teacher2"} + json={"students": valid_students}, headers={"Authorization": "teacher2"} ) assert response.status_code == 403 - def test_get_courses(self, valid_course_entries, client): """ Test all the getters for the courses endpoint """ - response = client.get("/courses", headers={"Authorization":"teacher1"}) + response = client.get( + "/courses", headers={"Authorization": "teacher1"}) assert response.status_code == 200 data = response.json for course in valid_course_entries: @@ -54,13 +70,13 @@ def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" response = client.delete( - "/courses/" + str(valid_course_entry.course_id), headers={"Authorization":"teacher2"} + "/courses/" + str(valid_course_entry.course_id), headers={"Authorization": "teacher2"} ) assert response.status_code == 200 # Is not reachable using the API get_response = client.get(f"/courses/{valid_course_entry.course_id}", - headers={"Authorization":"teacher2"}) + headers={"Authorization": "teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): @@ -69,7 +85,7 @@ def test_course_patch(self, valid_course_entry, client): """ response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ "name": "TestTest" - }, headers={"Authorization":"teacher2"}) + }, headers={"Authorization": "teacher2"}) data = response.json assert response.status_code == 200 assert data["data"]["name"] == "TestTest" From 3336fd400655d0f5a632e6352c9d131c2fae646d Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:54:00 +0100 Subject: [PATCH 142/144] started initial readme for repo (#122) * started initial readme for repo * added workflow badges * readme edits * spelling mistakes * grammar fix --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c684e92f..0c446923 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ -# UGent-3 \ No newline at end of file +# UGent-3 project peristerónas +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) +## Introduction +Project peristerónas was created to aid both teachers and students in achieving a +clear overview of deadlines and projects that need to be submitted. + +There's a separate functionality depending on if you're logged in as a teacher or as a student. +For students the main functionality is to have a user-friendly interface to submit projects and check the correctness of their submissions. + +When a teacher is logged in they can get an overview of the projects he assigned and check how many students have already +handed in a correct solution for example. It's also possible to edit the project and to grade projects in peristerónas' interface. +## Usage +### Frontend +For the developer instructions of the frontend please refer to the [frontend readme](frontend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. +### Backend +For the developer instructions of the backend please refer to the [backend readme](backend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. From 6aa0f0c96fc2d6b77137b6ae1ade48dd4a596b11 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Wed, 27 Mar 2024 12:54:41 +0100 Subject: [PATCH 143/144] fixed route /docs and edited projects endpoint (#123) * fixed route /docs and edited projects endpoint * linter+test succeed * fix: remove duplicate json file * edited env variable for failing tests * linter --- backend/project/__init__.py | 2 + .../project/endpoints/docs/docs_endpoint.py | 17 + backend/project/endpoints/index/index.py | 7 +- .../index => static}/OpenAPI_Object.json | 1662 ++++++++++------- backend/requirements.txt | 1 + backend/tests.yaml | 2 + 6 files changed, 973 insertions(+), 718 deletions(-) create mode 100644 backend/project/endpoints/docs/docs_endpoint.py rename backend/project/{endpoints/index => static}/OpenAPI_Object.json (66%) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 664ff947..9c71aafd 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -10,6 +10,7 @@ from .endpoints.projects.project_endpoint import project_bp from .endpoints.submissions import submissions_bp from .endpoints.courses.join_codes.join_codes_config import join_codes_bp +from .endpoints.docs.docs_endpoint import swagger_ui_blueprint def create_app(): """ @@ -25,6 +26,7 @@ def create_app(): app.register_blueprint(project_bp) app.register_blueprint(submissions_bp) app.register_blueprint(join_codes_bp) + app.register_blueprint(swagger_ui_blueprint) return app diff --git a/backend/project/endpoints/docs/docs_endpoint.py b/backend/project/endpoints/docs/docs_endpoint.py new file mode 100644 index 00000000..197641ae --- /dev/null +++ b/backend/project/endpoints/docs/docs_endpoint.py @@ -0,0 +1,17 @@ +""" +Module for defining the swagger docs +""" + +from os import getenv +from flask_swagger_ui import get_swaggerui_blueprint + +SWAGGER_URL = getenv("DOCS_URL") +API_URL = getenv("DOCS_JSON_PATH") + +swagger_ui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + f"/{API_URL}", + config={ + 'app_name': 'Pigeonhole API' + } +) diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py index 1bfe67cb..4feb3382 100644 --- a/backend/project/endpoints/index/index.py +++ b/backend/project/endpoints/index/index.py @@ -1,11 +1,13 @@ """Index api point""" import os -from flask import Blueprint, send_from_directory +from flask import Blueprint, send_file from flask_restful import Resource, Api index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) +API_URL = os.getenv("DOCS_JSON_PATH") + class Index(Resource): """Api endpoint for the / route""" @@ -14,8 +16,7 @@ def get(self): Example of an api endpoint function that will respond to get requests made to return a json data structure with key Message and value Hello World! """ - dir_path = os.path.dirname(os.path.realpath(__file__)) - return send_from_directory(dir_path, "OpenAPI_Object.json") + return send_file(API_URL) index_bp.add_url_rule("/", view_func=Index.as_view("index")) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/static/OpenAPI_Object.json similarity index 66% rename from backend/project/endpoints/index/OpenAPI_Object.json rename to backend/project/static/OpenAPI_Object.json index 5ac3ef53..ba0b5381 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/static/OpenAPI_Object.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "Pigeonhole API", "summary": "A project submission and grading API for University Ghent students and professors.", @@ -56,7 +56,7 @@ "type": "object", "properties": { "project_id": { - "type": "int" + "type": "integer" }, "description": { "type": "string" @@ -74,6 +74,27 @@ }, "post": { "description": "Upload a new project", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + }, + "required": ["assignment_file", "title", "description", "course_id", "visible_for_students", "archived"] + } + } + } + }, "responses": { "201": { "description": "Uploaded a new project succesfully", @@ -82,7 +103,51 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { + "type": "string" + }, + "data": { + "type": "object" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad formatted request for uploading a project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong inserting model into the database", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error":{ + "type": "string" + }, + "url": { + "type": "string" + } } } } @@ -94,6 +159,17 @@ "/projects/{id}": { "get": { "description": "Return a project with corresponding id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "A project with corresponding id", @@ -102,44 +178,39 @@ "schema": { "type": "object", "properties": { - "archived": { - "type": "bool" + "project_id": { + "type": "integer" }, - "assignment_file": { + "title": { "type": "string" }, - "course_id": { - "type": "int" + "description": { + "type": "string" }, - "deadline": { - "type": "date" + "assignment_file": { + "type": "string", + "format": "binary" }, - "description": { - "type": "array", - "items": { - "description": "string" - } + "deadline": { + "type": "string" }, - "project_id": { - "type": "int" + "course_id": { + "type": "integer" }, - "regex_expressions": { - "type": "array", - "items": { - "regex": "string" - } + "visible_for_students": { + "type": "boolean" }, - "script_name": { - "type": "string" + "archived": { + "type": "boolean" }, "test_path": { "type": "string" }, - "title": { + "script_name": { "type": "string" }, - "visible_for_students": { - "type": "bool" + "regex_expressions": { + "type": "array" } } } @@ -153,8 +224,32 @@ "schema": { "type": "object", "properties": { + "data": { + "type": "object" + }, "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something in the database went wrong fetching the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -165,6 +260,37 @@ }, "patch": { "description": "Patch certain fields of a project", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + } + } + } + } + }, "responses": { "200": { "description": "Patched a project succesfully", @@ -173,7 +299,13 @@ "schema": { "type": "object", "properties": { - "message": "string" + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "url": { "type": "string" } } } } @@ -188,6 +320,27 @@ "properties": { "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to patch the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -198,6 +351,17 @@ }, "delete": { "description": "Delete a project with given id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "Removed a project succesfully", @@ -206,7 +370,8 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } } } } @@ -219,7 +384,22 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to remove the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "url": { "type": "string" } } } } @@ -288,34 +468,34 @@ } } } - }, - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the course", - "schema": { - "type": "string" - } - }, - { - "name": "ufora_id", - "in": "query", - "description": "Ufora ID of the course", - "schema": { - "type": "string" - } - }, - { - "name": "teacher", - "in": "query", - "description": "Teacher of the course", - "schema": { - "type": "string" - } - } - ] }, + "parameters": [ + { + "name": "name", + "in": "query", + "description": "Name of the course", + "schema": { + "type": "string" + } + }, + { + "name": "ufora_id", + "in": "query", + "description": "Ufora ID of the course", + "schema": { + "type": "string" + } + }, + { + "name": "teacher", + "in": "query", + "description": "Teacher of the course", + "schema": { + "type": "string" + } + } + ] + }, "post": { "description": "Create a new course.", "requestBody": { @@ -333,37 +513,25 @@ "description": "Teacher of the course" } }, - "required": ["name", "teacher"] + "required": [ + "name", + "teacher" + ] } } } }, - "parameters":[ + "parameters": [ { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, + "responses": { "201": { "description": "Course with name: {name} and course_id: {course_id} was successfully created", "content": { @@ -399,8 +567,8 @@ } } }, - "403": { - "description": "The user trying to create a course was unauthorized.", + "400": { + "description": "There was no uid in the request query.", "content": { "application/json": { "schema": { @@ -414,8 +582,8 @@ } } }, - "500": { - "description": "Internal server error.", + "403": { + "description": "The user trying to create a course was unauthorized.", "content": { "application/json": { "schema": { @@ -443,18 +611,34 @@ } } } - } - } - }}, - "/courses/{course_id}" : { - "get": { - "description": "Get a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/courses/{course_id}": { + "get": { + "description": "Get a course by its ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, "schema": { "type": "string" } @@ -563,18 +747,22 @@ "properties": { "message": { "type": "string", - "example": "Successfully deleted course with course_id: {course_id}" + "examples": [ + "Successfully deleted course with course_id: {course_id}" + ] }, "url": { "type": "string", - "example": "{API_URL}/courses" + "examples": [ + "{API_URL}/courses" + ] } } } } } }, - "403" : { + "403": { "description": "The user trying to delete the course was unauthorized.", "content": { "application/json": { @@ -621,7 +809,7 @@ } } }, - "patch":{ + "patch": { "description": "Update the course with given ID.", "parameters": [ { @@ -642,25 +830,22 @@ "properties": { "name": { "type": "string", - "description": "Name of the course", - "required" : false + "description": "Name of the course" }, "teacher": { "type": "string", - "description": "Teacher of the course", - "required" : false + "description": "Teacher of the course" }, "ufora_id": { "type": "string", - "description": "Ufora ID of the course", - "required" : false + "description": "Ufora ID of the course" } } } } } }, - "responses" : { + "responses": { "200": { "description": "Course updated.", "content": { @@ -696,7 +881,7 @@ } } }, - "403" : { + "403": { "description": "The user trying to update the course was unauthorized.", "content": { "application/json": { @@ -812,7 +997,7 @@ } } }, - "post":{ + "post": { "description": "Assign students to a course.", "parameters": [ { @@ -834,30 +1019,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "Students assigned to course.", "content": { @@ -867,11 +1037,15 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully added to the course" + "examples": [ + "User were succesfully added to the course" + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -880,7 +1054,9 @@ "type": "array", "items": { "type": "string", - "example": "http://api.example.com/users/123" + "examples": [ + "http://api.example.com/users/123" + ] } } } @@ -890,6 +1066,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign students to the course was unauthorized.", "content": { @@ -937,7 +1128,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove students from a course.", "parameters": [ { @@ -959,15 +1150,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "Students removed from course.", "content": { @@ -977,11 +1168,13 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully removed from the course" + "examples": "User were succesfully removed from the course" }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1003,7 +1196,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove students from the course was unauthorized.", "content": { "application/json": { @@ -1018,8 +1211,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1033,8 +1226,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1048,8 +1241,8 @@ } } } - } - } + } + } }, "/courses/{course_id}/admins": { "get": { @@ -1119,7 +1312,7 @@ } } }, - "post":{ + "post": { "description": "Assign admins to a course.", "parameters": [ { @@ -1141,30 +1334,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "User were successfully added to the course.", "content": { @@ -1174,11 +1352,15 @@ "properties": { "message": { "type": "string", - "example": "User were successfully added to the course." + "examples": [ + "User were successfully added to the course." + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -1188,7 +1370,10 @@ "items": { "type": "string" }, - "example": ["http://api.example.com/users/1", "http://api.example.com/users/2"] + "examples": [ + "http://api.example.com/users/1", + "http://api.example.com/users/2" + ] } } } @@ -1197,6 +1382,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign admins to the course was unauthorized.", "content": { @@ -1244,7 +1444,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove an admin from a course.", "parameters": [ { @@ -1266,15 +1466,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "User was successfully removed from the course admins.", "content": { @@ -1284,11 +1484,15 @@ "properties": { "message": { "type": "string", - "example": "User was successfully removed from the course admins." + "examples": [ + "User was successfully removed from the course admins." + ] }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1310,7 +1514,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove the admin from the course was unauthorized.", "content": { "application/json": { @@ -1325,8 +1529,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1340,8 +1544,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1374,362 +1578,389 @@ "uid": { "type": "string" }, - "role": { - "type": "enum" - } - }, - "required": ["uid", "role"] - } - } - } - } - }}}, - "post": { - "summary": "Create a new user", - "requestBody": { - "required": true, - "content":{ - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" + "is_teacher": { + "type": "boolean" }, - "role": { - "type": "enum" + "is_admin": { + "type": "boolean" } }, - "required": ["uid", "role"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } - }, - "responses": { - "201": { - "description": "User created successfully" - }, - "400": { - "description": "Invalid request data" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while creating the user" - } - } - - }, - "/users/{user_id}": { - "get": { - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "role": { - "type": "enum" - } - }, - "required": ["uid", "role"] - } + } + } + } + }, + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" } - } - }, - "404": { - "description": "User not found" + }, + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } + } + }, + "responses": { + "201": { + "description": "User created successfully" }, - "patch": { - "summary": "Update a user's information", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { + "400": { + "description": "Invalid request data" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while creating the user" + } + } + }, + "/users/{user_id}": { + "get": { + "summary": "Get a user by ID", + "parameters": [ + { + "name": "user_id", + "in": "path", "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A user", "content": { "application/json": { "schema": { "type": "object", "properties": { - "role": { - "type": "role" + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" } }, - "required": ["role"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } }, - "responses": { - "200": { - "description": "User updated successfully" - }, - "404": { - "description": "User not found" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while patching the user" - } - } - }, - "delete": { - "summary": "Delete a user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "500": { - "description": "An error occurred while deleting the user" - } + "404": { + "description": "User not found" } } - } - } - }, - "/submissions": { - "get": { - "summary": "Gets the submissions", - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "User ID", - "schema": { - "type": "string" - } - }, - { - "name": "project_id", - "in": "query", - "description": "Project ID", - "schema": { - "type": "integer" + }, + "patch": { + "summary": "Update a user's information", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved a list of submission URLs", + ], + "requestBody": { + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "is_teacher": { + "type": "boolean" }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submissions": "array", - "items": { - "type": "string", - "format": "uri" - } - } + "is_admin": { + "type": "boolean" } - } + }, + "required": [ + "is_teacher", + "is_admin" + ] } } } }, - "400": { - "description": "An invalid user or project is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" + "responses": { + "200": { + "description": "User updated successfully" + }, + "404": { + "description": "User not found" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while patching the user" + } + } + }, + "delete": { + "summary": "Delete a user", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "An error occurred while deleting the user" + } + } + } + } + } + }, + "/submissions": { + "get": { + "summary": "Gets the submissions", + "parameters": [ + { + "name": "uid", + "in": "query", + "description": "User ID", + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved a list of submission URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submissions": "array", + "items": { + "type": "string", + "format": "uri" + } } } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user or project is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } - } - }, - "post": { - "summary": "Posts a new submission to a project", - "requestBody": { - "description": "Form data", + }, + "500": { + "description": "An internal server error occurred", "content": { "application/json": { "schema": { "type": "object", "properties": { - "uid": { + "url": { "type": "string", - "required": true - }, - "project_id": { - "type": "integer", - "required": true + "format": "uri" }, - "files": { - "type": "array", - "items": { - "type": "file" - } + "message": { + "type": "string" } } } } } - }, - "responses": { - "201": { - "description": "Successfully posts the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + } + }, + "post": { + "summary": "Posts a new submission to a project", + "requestBody": { + "description": "Form data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "required": true + }, + "project_id": { + "type": "integer", + "required": true + }, + "files": { + "type": "array", + "items": { + "type": "file" + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Successfully posts the submission and retrieves its data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid user, project or list of files is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user, project or list of files is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } @@ -1737,56 +1968,56 @@ } } } - }, - "/submissions/{submission_id}": { - "get": { - "summary": "Gets the submission", - "responses": { - "200": { - "description": "Successfully retrieved the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submission": { - "type": "object", - "properties": { - "submission_id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer", - "nullable": true - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + }, + "/submissions/{submission_id}": { + "get": { + "summary": "Gets the submission", + "responses": { + "200": { + "description": "Successfully retrieved the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "submission_id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer", + "nullable": true + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } @@ -1795,246 +2026,247 @@ } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } + } + }, + "patch": { + "summary": "Patches the submission", + "requestBody": { + "description": "The submission data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "integer", + "minimum": 0, + "maximum": 20 + } + } + } + } + } }, - "patch": { - "summary": "Patches the submission", - "requestBody": { - "description": "The submission data", + "responses": { + "200": { + "description": "Successfully patches the submission and retrieves its data", "content": { "application/json": { "schema": { "type": "object", "properties": { - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successfully patches the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid submission grading is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid submission grading is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "delete": { - "summary": "Deletes the submission", - "responses": { - "200": { - "description": "Successfully deletes the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "delete": { + "summary": "Deletes the submission", + "responses": { + "200": { + "description": "Successfully deletes the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "parameters": [ - { - "name": "submission_id", - "in": "path", - "description": "Submission ID", - "required": true, - "schema": { - "type": "integer" - } + } + }, + "parameters": [ + { + "name": "submission_id", + "in": "path", + "description": "Submission ID", + "required": true, + "schema": { + "type": "integer" } - ] - } + } + ] } +} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 1bbc2e9e..7d997769 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ pytest~=8.0.1 SQLAlchemy~=2.0.27 requests>=2.31.0 waitress +flask_swagger_ui diff --git a/backend/tests.yaml b/backend/tests.yaml index 397702d8..dad5289a 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -43,6 +43,8 @@ services: API_HOST: http://api_is_here AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments + DOCS_JSON_PATH: static/OpenAPI_Object.json + DOCS_URL: /docs volumes: - .:/app command: ["pytest"] From f8fee400cd021b4794668e4703f182b8de5001d4 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Wed, 27 Mar 2024 17:43:49 +0100 Subject: [PATCH 144/144] Base setup for easy translations in frontend (#145) * i18n setup * added dependencies --- frontend/package-lock.json | 136 ++++++++++++++++++++ frontend/package.json | 4 + frontend/public/locales/en/translation.json | 7 + frontend/public/locales/nl/translation.json | 7 + frontend/src/components/Header/Header.tsx | 4 +- frontend/src/i18n.js | 19 +++ frontend/src/main.tsx | 1 + frontend/src/pages/home/Home.tsx | 4 +- 8 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 frontend/public/locales/en/translation.json create mode 100644 frontend/public/locales/nl/translation.json create mode 100644 frontend/src/i18n.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4fc8aea3..8467e285 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", + "react-i18next": "^14.1.0", "typescript": "^5.2.2", "vite": "^5.1.0" } @@ -2731,6 +2735,15 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4002,6 +4015,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -4025,6 +4047,47 @@ "node": ">=8.12.0" } }, + "node_modules/i18next": { + "version": "23.10.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.1.tgz", + "integrity": "sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", + "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.0.tgz", + "integrity": "sha512-Z/aQsGZk1gSxt2/DztXk92DuDD20J+rNudT7ZCdTrNOiK8uQppfvdjq9+DFQfpAnFPn3VZS+KQIr1S/W1KxhpQ==", + "dev": true, + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4780,6 +4843,26 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -5186,6 +5269,28 @@ "react": "^18.2.0" } }, + "node_modules/react-i18next": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.0.tgz", + "integrity": "sha512-3KwX6LHpbvGQ+sBEntjV4sYW3Zovjjl3fpoHbUwSgFHf0uRBcbeCBLR5al6ikncI5+W0EFb71QXZmfop+J6NrQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -5865,6 +5970,12 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/ts-api-utils": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", @@ -6089,6 +6200,31 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4645a37b..dfb8c6fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", + "react-i18next": "^14.1.0", "typescript": "^5.2.2", "vite": "^5.1.0" } diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json new file mode 100644 index 00000000..1447580c --- /dev/null +++ b/frontend/public/locales/en/translation.json @@ -0,0 +1,7 @@ +{ + "homepage": "Homepage", + "myProjects": "My Projects", + "myCourses": "My Courses", + "login": "Login", + "home": "Home" + } \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json new file mode 100644 index 00000000..c852df96 --- /dev/null +++ b/frontend/public/locales/nl/translation.json @@ -0,0 +1,7 @@ +{ + "homepage": "Homepage", + "myProjects": "Mijn Projecten", + "myCourses": "Mijn Vakken", + "login": "Login", + "home": "Home" + } \ No newline at end of file diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 860086fd..8595e6d4 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -7,12 +7,14 @@ import { Typography, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; +import { useTranslation } from "react-i18next"; /** * The header component for the application that will be rendered at the top of the page. * @returns - The header component */ export function Header(): JSX.Element { + const { t } = useTranslation(); return ( @@ -21,7 +23,7 @@ export function Header(): JSX.Element { - Home + {t('home')} diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 00000000..98055d4a --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,19 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: true, + + interpolation: { + escapeValue: false, + } + }); + +export default i18n; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3d7150da..9b684efc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' +import './i18n' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 80610e7c..344fb124 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,12 +1,14 @@ +import { useTranslation } from "react-i18next"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { + const { t } = useTranslation(); return (
-

HomePage

+

{t('homepage')}

); }