diff --git a/README.md b/README.md index c684e92f..0c446923 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ -# UGent-3 \ No newline at end of file +# UGent-3 project peristerĂ³nas +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) +## Introduction +Project peristerĂ³nas was created to aid both teachers and students in achieving a +clear overview of deadlines and projects that need to be submitted. + +There's a separate functionality depending on if you're logged in as a teacher or as a student. +For students the main functionality is to have a user-friendly interface to submit projects and check the correctness of their submissions. + +When a teacher is logged in they can get an overview of the projects he assigned and check how many students have already +handed in a correct solution for example. It's also possible to edit the project and to grade projects in peristerĂ³nas' interface. +## Usage +### Frontend +For the developer instructions of the frontend please refer to the [frontend readme](frontend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. +### Backend +For the developer instructions of the backend please refer to the [backend readme](backend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. diff --git a/backend/db_construct.sql b/backend/db_construct.sql index 7ce4413a..f8442c21 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,10 +1,11 @@ +CREATE TYPE role AS ENUM ('STUDENT', 'TEACHER', 'ADMIN'); + CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); CREATE TABLE users ( uid VARCHAR(255), display_name VARCHAR(255), - is_teacher BOOLEAN, - is_admin BOOLEAN, + role role NOT NULL, PRIMARY KEY(uid) ); diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 664ff947..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/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index cb36c6a4..4c01ee73 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -17,7 +17,8 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(f"{API_URL}/", "courses") +RESPONSE_URL = urljoin(API_URL + "/", "courses") +BASE_DB_ERROR = "Database error occurred while" def execute_query_abort_if_db_error(query, url, query_all=False): """ @@ -35,8 +36,8 @@ def execute_query_abort_if_db_error(query, url, query_all=False): result = query.all() else: result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) + except SQLAlchemyError: + response = json_message(f"{BASE_DB_ERROR} executing query") response["url"] = url abort(500, description=response) return result @@ -52,9 +53,9 @@ def add_abort_if_error(to_add, url): """ try: db.session.add(to_add) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} adding object") response["url"] = url abort(500, description=response) @@ -69,9 +70,9 @@ def delete_abort_if_error(to_delete, url): """ try: db.session.delete(to_delete) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} deleting object") response["url"] = url abort(500, description=response) @@ -82,9 +83,9 @@ def commit_abort_if_error(url): """ try: db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} committing changes") response["url"] = url abort(500, description=response) diff --git a/backend/project/endpoints/docs/docs_endpoint.py b/backend/project/endpoints/docs/docs_endpoint.py new file mode 100644 index 00000000..197641ae --- /dev/null +++ b/backend/project/endpoints/docs/docs_endpoint.py @@ -0,0 +1,17 @@ +""" +Module for defining the swagger docs +""" + +from os import getenv +from flask_swagger_ui import get_swaggerui_blueprint + +SWAGGER_URL = getenv("DOCS_URL") +API_URL = getenv("DOCS_JSON_PATH") + +swagger_ui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + f"/{API_URL}", + config={ + 'app_name': 'Pigeonhole API' + } +) diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py index 1bfe67cb..4feb3382 100644 --- a/backend/project/endpoints/index/index.py +++ b/backend/project/endpoints/index/index.py @@ -1,11 +1,13 @@ """Index api point""" import os -from flask import Blueprint, send_from_directory +from flask import Blueprint, send_file from flask_restful import Resource, Api index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) +API_URL = os.getenv("DOCS_JSON_PATH") + class Index(Resource): """Api endpoint for the / route""" @@ -14,8 +16,7 @@ def get(self): Example of an api endpoint function that will respond to get requests made to return a json data structure with key Message and value Hello World! """ - dir_path = os.path.dirname(os.path.realpath(__file__)) - return send_from_directory(dir_path, "OpenAPI_Object.json") + return send_file(API_URL) index_bp.add_url_rule("/", view_func=Index.as_view("index")) diff --git a/backend/project/endpoints/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 57ddbb35..2c64ad4c 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -1,19 +1,34 @@ """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, - a display_name, - is_teacher and is_admin booleans because a user - can be either a student, admin or teacher""" + """ + a user has a uid, + a display_name and a role, a user + can be either a student,admin or teacher + """ __tablename__ = "users" uid: str = Column(String(255), primary_key=True) display_name: str = Column(String(255)) - 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 + 'display_name': self.display_name + } diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/static/OpenAPI_Object.json similarity index 66% rename from backend/project/endpoints/index/OpenAPI_Object.json rename to backend/project/static/OpenAPI_Object.json index 829f7c38..ba0b5381 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/static/OpenAPI_Object.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "Pigeonhole API", "summary": "A project submission and grading API for University Ghent students and professors.", @@ -56,7 +56,7 @@ "type": "object", "properties": { "project_id": { - "type": "int" + "type": "integer" }, "description": { "type": "string" @@ -74,6 +74,27 @@ }, "post": { "description": "Upload a new project", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + }, + "required": ["assignment_file", "title", "description", "course_id", "visible_for_students", "archived"] + } + } + } + }, "responses": { "201": { "description": "Uploaded a new project succesfully", @@ -82,7 +103,51 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { + "type": "string" + }, + "data": { + "type": "object" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad formatted request for uploading a project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong inserting model into the database", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error":{ + "type": "string" + }, + "url": { + "type": "string" + } } } } @@ -94,6 +159,17 @@ "/projects/{id}": { "get": { "description": "Return a project with corresponding id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "A project with corresponding id", @@ -102,44 +178,39 @@ "schema": { "type": "object", "properties": { - "archived": { - "type": "bool" + "project_id": { + "type": "integer" }, - "assignment_file": { + "title": { "type": "string" }, - "course_id": { - "type": "int" + "description": { + "type": "string" }, - "deadline": { - "type": "date" + "assignment_file": { + "type": "string", + "format": "binary" }, - "description": { - "type": "array", - "items": { - "description": "string" - } + "deadline": { + "type": "string" }, - "project_id": { - "type": "int" + "course_id": { + "type": "integer" }, - "regex_expressions": { - "type": "array", - "items": { - "regex": "string" - } + "visible_for_students": { + "type": "boolean" }, - "script_name": { - "type": "string" + "archived": { + "type": "boolean" }, "test_path": { "type": "string" }, - "title": { + "script_name": { "type": "string" }, - "visible_for_students": { - "type": "bool" + "regex_expressions": { + "type": "array" } } } @@ -153,8 +224,32 @@ "schema": { "type": "object", "properties": { + "data": { + "type": "object" + }, "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something in the database went wrong fetching the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -165,6 +260,37 @@ }, "patch": { "description": "Patch certain fields of a project", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + } + } + } + } + }, "responses": { "200": { "description": "Patched a project succesfully", @@ -173,7 +299,13 @@ "schema": { "type": "object", "properties": { - "message": "string" + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "url": { "type": "string" } } } } @@ -188,6 +320,27 @@ "properties": { "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to patch the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -198,6 +351,17 @@ }, "delete": { "description": "Delete a project with given id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "Removed a project succesfully", @@ -206,7 +370,8 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } } } } @@ -219,7 +384,22 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to remove the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "url": { "type": "string" } } } } @@ -288,34 +468,34 @@ } } } - }, - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the course", - "schema": { - "type": "string" - } - }, - { - "name": "ufora_id", - "in": "query", - "description": "Ufora ID of the course", - "schema": { - "type": "string" - } - }, - { - "name": "teacher", - "in": "query", - "description": "Teacher of the course", - "schema": { - "type": "string" - } - } - ] }, + "parameters": [ + { + "name": "name", + "in": "query", + "description": "Name of the course", + "schema": { + "type": "string" + } + }, + { + "name": "ufora_id", + "in": "query", + "description": "Ufora ID of the course", + "schema": { + "type": "string" + } + }, + { + "name": "teacher", + "in": "query", + "description": "Teacher of the course", + "schema": { + "type": "string" + } + } + ] + }, "post": { "description": "Create a new course.", "requestBody": { @@ -333,37 +513,25 @@ "description": "Teacher of the course" } }, - "required": ["name", "teacher"] + "required": [ + "name", + "teacher" + ] } } } }, - "parameters":[ + "parameters": [ { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, + "responses": { "201": { "description": "Course with name: {name} and course_id: {course_id} was successfully created", "content": { @@ -399,8 +567,8 @@ } } }, - "403": { - "description": "The user trying to create a course was unauthorized.", + "400": { + "description": "There was no uid in the request query.", "content": { "application/json": { "schema": { @@ -414,8 +582,8 @@ } } }, - "500": { - "description": "Internal server error.", + "403": { + "description": "The user trying to create a course was unauthorized.", "content": { "application/json": { "schema": { @@ -443,18 +611,34 @@ } } } - } - } - }}, - "/courses/{course_id}" : { - "get": { - "description": "Get a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/courses/{course_id}": { + "get": { + "description": "Get a course by its ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, "schema": { "type": "string" } @@ -563,18 +747,22 @@ "properties": { "message": { "type": "string", - "example": "Successfully deleted course with course_id: {course_id}" + "examples": [ + "Successfully deleted course with course_id: {course_id}" + ] }, "url": { "type": "string", - "example": "{API_URL}/courses" + "examples": [ + "{API_URL}/courses" + ] } } } } } }, - "403" : { + "403": { "description": "The user trying to delete the course was unauthorized.", "content": { "application/json": { @@ -621,7 +809,7 @@ } } }, - "patch":{ + "patch": { "description": "Update the course with given ID.", "parameters": [ { @@ -642,25 +830,22 @@ "properties": { "name": { "type": "string", - "description": "Name of the course", - "required" : false + "description": "Name of the course" }, "teacher": { "type": "string", - "description": "Teacher of the course", - "required" : false + "description": "Teacher of the course" }, "ufora_id": { "type": "string", - "description": "Ufora ID of the course", - "required" : false + "description": "Ufora ID of the course" } } } } } }, - "responses" : { + "responses": { "200": { "description": "Course updated.", "content": { @@ -696,7 +881,7 @@ } } }, - "403" : { + "403": { "description": "The user trying to update the course was unauthorized.", "content": { "application/json": { @@ -812,7 +997,7 @@ } } }, - "post":{ + "post": { "description": "Assign students to a course.", "parameters": [ { @@ -834,30 +1019,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "Students assigned to course.", "content": { @@ -867,11 +1037,15 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully added to the course" + "examples": [ + "User were succesfully added to the course" + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -880,7 +1054,9 @@ "type": "array", "items": { "type": "string", - "example": "http://api.example.com/users/123" + "examples": [ + "http://api.example.com/users/123" + ] } } } @@ -890,6 +1066,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign students to the course was unauthorized.", "content": { @@ -937,7 +1128,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove students from a course.", "parameters": [ { @@ -959,15 +1150,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "Students removed from course.", "content": { @@ -977,11 +1168,13 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully removed from the course" + "examples": "User were succesfully removed from the course" }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1003,7 +1196,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove students from the course was unauthorized.", "content": { "application/json": { @@ -1018,8 +1211,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1033,8 +1226,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1048,8 +1241,8 @@ } } } - } - } + } + } }, "/courses/{course_id}/admins": { "get": { @@ -1119,7 +1312,7 @@ } } }, - "post":{ + "post": { "description": "Assign admins to a course.", "parameters": [ { @@ -1141,30 +1334,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "User were successfully added to the course.", "content": { @@ -1174,11 +1352,15 @@ "properties": { "message": { "type": "string", - "example": "User were successfully added to the course." + "examples": [ + "User were successfully added to the course." + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -1188,7 +1370,10 @@ "items": { "type": "string" }, - "example": ["http://api.example.com/users/1", "http://api.example.com/users/2"] + "examples": [ + "http://api.example.com/users/1", + "http://api.example.com/users/2" + ] } } } @@ -1197,6 +1382,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign admins to the course was unauthorized.", "content": { @@ -1244,7 +1444,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove an admin from a course.", "parameters": [ { @@ -1266,15 +1466,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "User was successfully removed from the course admins.", "content": { @@ -1284,11 +1484,15 @@ "properties": { "message": { "type": "string", - "example": "User was successfully removed from the course admins." + "examples": [ + "User was successfully removed from the course admins." + ] }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1310,7 +1514,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove the admin from the course was unauthorized.", "content": { "application/json": { @@ -1325,8 +1529,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1340,8 +1544,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1381,112 +1585,85 @@ "type": "boolean" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } } - }}}, - "post": { - "summary": "Create a new user", - "requestBody": { - "required": true, - "content":{ - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": ["uid", "is_teacher", "is_admin"] - } - } - } - }, - "responses": { - "201": { - "description": "User created successfully" - }, - "400": { - "description": "Invalid request data" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while creating the user" - } - } - - }, - "/users/{user_id}": { - "get": { - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": ["uid", "is_teacher", "is_admin"] - } + } + } + }, + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" } - } - }, - "404": { - "description": "User not found" + }, + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } + } + }, + "responses": { + "201": { + "description": "User created successfully" }, - "patch": { - "summary": "Update a user's information", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { + "400": { + "description": "Invalid request data" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while creating the user" + } + } + }, + "/users/{user_id}": { + "get": { + "summary": "Get a user by ID", + "parameters": [ + { + "name": "user_id", + "in": "path", "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A user", "content": { "application/json": { "schema": { "type": "object", "properties": { + "uid": { + "type": "string" + }, "is_teacher": { "type": "boolean" }, @@ -1494,254 +1671,296 @@ "type": "boolean" } }, - "required": ["is_teacher", "is_admin"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } }, - "responses": { - "200": { - "description": "User updated successfully" - }, - "404": { - "description": "User not found" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while patching the user" - } - } - }, - "delete": { - "summary": "Delete a user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "500": { - "description": "An error occurred while deleting the user" - } + "404": { + "description": "User not found" } } - } - } - }, - "/submissions": { - "get": { - "summary": "Gets the submissions", - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "User ID", - "schema": { - "type": "string" - } - }, - { - "name": "project_id", - "in": "query", - "description": "Project ID", - "schema": { - "type": "integer" + }, + "patch": { + "summary": "Update a user's information", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved a list of submission URLs", + ], + "requestBody": { + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "is_teacher": { + "type": "boolean" }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submissions": "array", - "items": { - "type": "string", - "format": "uri" - } - } + "is_admin": { + "type": "boolean" } - } + }, + "required": [ + "is_teacher", + "is_admin" + ] } } } }, - "400": { - "description": "An invalid user or project is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" + "responses": { + "200": { + "description": "User updated successfully" + }, + "404": { + "description": "User not found" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while patching the user" + } + } + }, + "delete": { + "summary": "Delete a user", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "An error occurred while deleting the user" + } + } + } + } + } + }, + "/submissions": { + "get": { + "summary": "Gets the submissions", + "parameters": [ + { + "name": "uid", + "in": "query", + "description": "User ID", + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved a list of submission URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submissions": "array", + "items": { + "type": "string", + "format": "uri" + } } } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user or project is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } - } - }, - "post": { - "summary": "Posts a new submission to a project", - "requestBody": { - "description": "Form data", + }, + "500": { + "description": "An internal server error occurred", "content": { "application/json": { "schema": { "type": "object", "properties": { - "uid": { + "url": { "type": "string", - "required": true + "format": "uri" }, - "project_id": { - "type": "integer", - "required": true - }, - "files": { - "type": "array", - "items": { - "type": "file" - } + "message": { + "type": "string" } } } } } - }, - "responses": { - "201": { - "description": "Successfully posts the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + } + }, + "post": { + "summary": "Posts a new submission to a project", + "requestBody": { + "description": "Form data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "required": true + }, + "project_id": { + "type": "integer", + "required": true + }, + "files": { + "type": "array", + "items": { + "type": "file" + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Successfully posts the submission and retrieves its data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid user, project or list of files is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user, project or list of files is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } @@ -1749,56 +1968,56 @@ } } } - }, - "/submissions/{submission_id}": { - "get": { - "summary": "Gets the submission", - "responses": { - "200": { - "description": "Successfully retrieved the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submission": { - "type": "object", - "properties": { - "submission_id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer", - "nullable": true - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + }, + "/submissions/{submission_id}": { + "get": { + "summary": "Gets the submission", + "responses": { + "200": { + "description": "Successfully retrieved the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "submission_id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer", + "nullable": true + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } @@ -1807,246 +2026,247 @@ } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } + } + }, + "patch": { + "summary": "Patches the submission", + "requestBody": { + "description": "The submission data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "integer", + "minimum": 0, + "maximum": 20 + } + } + } + } + } }, - "patch": { - "summary": "Patches the submission", - "requestBody": { - "description": "The submission data", + "responses": { + "200": { + "description": "Successfully patches the submission and retrieves its data", "content": { "application/json": { "schema": { "type": "object", "properties": { - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successfully patches the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid submission grading is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid submission grading is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "delete": { - "summary": "Deletes the submission", - "responses": { - "200": { - "description": "Successfully deletes the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "delete": { + "summary": "Deletes the submission", + "responses": { + "200": { + "description": "Successfully deletes the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "parameters": [ - { - "name": "submission_id", - "in": "path", - "description": "Submission ID", - "required": true, - "schema": { - "type": "integer" - } + } + }, + "parameters": [ + { + "name": "submission_id", + "in": "path", + "description": "Submission ID", + "required": true, + "schema": { + "type": "integer" } - ] - } + } + ] } +} \ No newline at end of file diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index ec764a6a..128ea8f7 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,27 +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 - teacher_status = False + + # Use the Enum here + role = Role.STUDENT if user_info["jobTitle"] is not None: - teacher_status = True + role = Role.TEACHER # add user if not yet in database try: - new_user = User(uid=auth_user_id, is_teacher=teacher_status, - is_admin=False, display_name=user_info["displayName"]) + new_user = User(uid=auth_user_id, role=role, display_name=user_info["displayName"]) 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 @@ -105,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 @@ -122,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 @@ -139,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 @@ -154,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 @@ -175,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 @@ -195,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 @@ -212,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 @@ -233,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 @@ -252,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 @@ -274,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 @@ -291,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 @@ -304,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 @@ -324,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/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..01368eb3 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -61,7 +61,7 @@ def create_model_instance(model: DeclarativeMeta, if required_fields is None: required_fields = [] # Check if all non-nullable fields are present in the data - missing_fields = [field for field in required_fields if field not in data] + missing_fields = [field for field in required_fields if field not in data or data[field] == ''] if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", diff --git a/backend/requirements.txt b/backend/requirements.txt index 1bbc2e9e..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/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 78fdb792..c745e461 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -76,4 +76,4 @@ def get(self): app = Flask(__name__) app.register_blueprint(index_bp) -app.run(debug=True, host='0.0.0.0') +app.run(debug=True, host='0.0.0.0', port=5001) diff --git a/backend/tests.yaml b/backend/tests.yaml index 6238d2ec..dad5289a 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -41,8 +41,10 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose + AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments + DOCS_JSON_PATH: static/OpenAPI_Object.json + DOCS_URL: /docs volumes: - .:/app command: ["pytest"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc605602..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..8a1c53ab 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() @@ -217,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""" @@ -229,7 +238,7 @@ def valid_course_entry(session, valid_course): def valid_students_entries(session): """Valid students for testing that are already in the db""" students = [ - User(uid=f"student_sel2_{i}", is_teacher=False) + User(uid=f"student_sel2_{i}", role=Role.STUDENT) for i in range(3) ] session.add_all(students) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 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" 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""" 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')}

); }