diff --git a/backend/.gitignore b/backend/.gitignore index 25343357..72cc6e44 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -9,3 +9,4 @@ docs/_build/ dist/ venv/ .env +.run/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 8e4fa18e..558bf735 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,9 @@ FROM python:3.9 RUN mkdir /app -WORKDIR /app/ +WORKDIR /app ADD ./project /app/ +COPY requirements.txt /app/requirements.txt RUN pip3 install -r requirements.txt -CMD ["python3", "/app"] \ No newline at end of file +COPY . /app +ENTRYPOINT ["python"] +CMD ["__main__.py"] \ No newline at end of file diff --git a/backend/project/__init__.py b/backend/project/__init__.py index b0c21275..299412f1 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -7,6 +7,7 @@ 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.users import users_bp def create_app(): @@ -20,6 +21,7 @@ def create_app(): app.register_blueprint(index_bp) app.register_blueprint(project_bp) app.register_blueprint(courses_bp) + app.register_blueprint(users_bp) return app diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3409a17b..124bdb8b 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1357,7 +1357,191 @@ } } } + }, + "/users": { + "get": { + "summary": "Get all users", + "responses": { + "200": { + "description": "A list of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["uid", "is_teacher", "is_admin"] + } + } + } + } + }}}, + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content":{ + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["uid", "is_teacher", "is_admin"] + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully" + }, + "400": { + "description": "Invalid request data" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while creating the user" + } + } + + }, + "/users/{user_id}": { + "get": { + "summary": "Get a user by ID", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["uid", "is_teacher", "is_admin"] + } + } + } + }, + "404": { + "description": "User not found" + } + } + }, + "patch": { + "summary": "Update a user's information", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["is_teacher", "is_admin"] + } + } + } + }, + "responses": { + "200": { + "description": "User updated successfully" + }, + "404": { + "description": "User not found" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while patching the user" + } + } + }, + "delete": { + "summary": "Delete a user", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "An error occurred while deleting the user" + } + } + } + } } } + + } diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py new file mode 100644 index 00000000..8dbe25d2 --- /dev/null +++ b/backend/project/endpoints/users.py @@ -0,0 +1,155 @@ +"""Users api endpoint""" +from os import getenv + +from dotenv import load_dotenv +from flask import Blueprint, request, jsonify +from flask_restful import Resource, Api +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.users import User as userModel + +users_bp = Blueprint("users", __name__) +users_api = Api(users_bp) + +load_dotenv() +API_URL = getenv("API_HOST") + +class Users(Resource): + """Api endpoint for the /users route""" + + def get(self): + """ + This function will respond to get requests made to /users. + It should return all users from the database. + """ + 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')) + + users = query.all() + + result = jsonify({"message": "Queried all users", "data": users, + "url":f"{API_URL}/users", "status_code": 200}) + return result + except SQLAlchemyError: + return {"message": "An error occurred while fetching the users", + "url": f"{API_URL}/users"}, 500 + + def post(self): + """ + This function will respond to post requests made to /users. + It should create a new user and return a success message. + """ + uid = request.json.get('uid') + is_teacher = request.json.get('is_teacher') + is_admin = request.json.get('is_admin') + + if is_teacher is None or is_admin is None or uid is None: + return { + "message": "Invalid request data!", + "correct_format": { + "uid": "User ID (string)", + "is_teacher": "Teacher status (boolean)", + "is_admin": "Admin status (boolean)" + },"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) + 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}) + + 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 + + + +class User(Resource): + """Api endpoint for the /users/{user_id} route""" + + def get(self, user_id): + """ + This function will respond to GET requests made to /users/. + It should return the user with the given user_id from the database. + """ + try: + user = db.session.get(userModel, user_id) + if user is None: + return {"message": "User not found!","url": f"{API_URL}/users"}, 404 + + return jsonify({"message": "User queried","data":user, + "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) + except SQLAlchemyError: + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + def patch(self, user_id): + """ + Update the user's information. + + Returns: + 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') + 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 + + # 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}) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while patching the user", + "url": f"{API_URL}/users"}, 500 + + + def delete(self, user_id): + """ + This function will respond to DELETE requests made to /users/. + It should delete the user with the given user_id from the database. + """ + try: + user = db.session.get(userModel, user_id) + if user is None: + return {"message": "User not found!", "url": f"{API_URL}/users"}, 404 + + db.session.delete(user) + db.session.commit() + return {"message": "User deleted successfully!", "url": f"{API_URL}/users"}, 200 + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while deleting the user", + "url": f"{API_URL}/users"}, 500 + + +users_api.add_resource(Users, "/users") +users_api.add_resource(User, "/users/") diff --git a/backend/project/models/users.py b/backend/project/models/users.py index d1b35a20..d325a60c 100644 --- a/backend/project/models/users.py +++ b/backend/project/models/users.py @@ -1,15 +1,17 @@ """Model for users""" +import dataclasses + from sqlalchemy import Boolean, Column, String from project import db - +@dataclasses.dataclass class User(db.Model): """This class defines the users table, - a user has an uid, + a user has a uid, is_teacher and is_admin booleans because a user can be either a student,admin or teacher""" __tablename__ = "users" - uid = Column(String(255), primary_key=True) - is_teacher = Column(Boolean) - is_admin = Column(Boolean) + uid:str = Column(String(255), primary_key=True) + is_teacher:bool = Column(Boolean) + is_admin:bool = Column(Boolean) diff --git a/backend/pylintrc b/backend/pylintrc index 83eff274..77376fe1 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -1,9 +1,15 @@ [MASTER] init-hook='import sys; sys.path.append(".")' +[MESSAGES CONTROL] +disable=W0621, # Redefining name %r from outer scope (line %s) + W0613 # unused-argument + + [test-files:*_test.py] disable= W0621, # Redefining name %r from outer scope (line %s) + W0613 # unused-argument [modules:project/modules/*] disable= diff --git a/backend/requirements.txt b/backend/requirements.txt index 943fb75a..0076b0f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,7 @@ -flask +flask~=3.0.2 flask-restful flask-sqlalchemy -python-dotenv -psycopg2-binary \ No newline at end of file +python-dotenv~=1.0.1 +psycopg2-binary +pytest~=8.0.1 +SQLAlchemy~=2.0.27 \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index e69de29b..ec43b874 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -0,0 +1,26 @@ +""" +This module is used to create a SQLAlchemy URL object for a PostgreSQL database. + +It uses environment variables to get the necessary database configuration details: +- 'POSTGRES_DB': The name of the database. +- 'POSTGRES_USER': The username to connect to the database. +- 'POSTGRES_PASSWORD': The password to connect to the database. +- 'POSTGRES_HOST': The host where the database is located. + +""" +import os +from sqlalchemy.engine.url import URL +from dotenv import load_dotenv +load_dotenv() + +DATABSE_NAME = os.getenv('POSTGRES_DB') +DATABASE_USER = os.getenv('POSTGRES_USER') +DATABASE_PASSWORD = os.getenv('POSTGRES_PASSWORD') +DATABASE_HOST = os.getenv('POSTGRES_HOST') +db_url = URL.create( + drivername="postgresql", + username=DATABASE_USER, + host=DATABASE_HOST, + database=DATABSE_NAME, + password=DATABASE_PASSWORD +) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py new file mode 100644 index 00000000..f6b76862 --- /dev/null +++ b/backend/tests/endpoints/user_test.py @@ -0,0 +1,126 @@ +""" +This module tests user management endpoints. + +- test_post_delete_user: Tests user creation, deletion, and error handling for deletion + of non-existent user. +- test_get_users: Tests retrieval of all users, ensuring the response is a list. +- test_patch_user: Tests user update functionality and error handling for updating + non-existent user. +""" +import pytest +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine +from project.models.users import User +from project import db +from tests import db_url + +engine = create_engine(db_url) +Session = sessionmaker(bind=engine) +@pytest.fixture +def user_db_session(): + """Create a new database session for the user tests. + After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) + session = Session() + session.add_all( + [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.commit() + yield session + session.rollback() + session.close() # pylint: disable=duplicate-code; + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() +class TestUserEndpoint: + """Class to test user management endpoints.""" + + def test_delete_user(self, client,user_db_session): + """Test deleting a user.""" + # Delete the user + response = client.delete("/users/del") + assert response.status_code == 200 + assert response.json["message"] == "User deleted successfully!" + + def test_delete_not_present(self, client,user_db_session): + """Test deleting a user that does not exist.""" + response = client.delete("/users/non") + assert response.status_code == 404 + + def test_wrong_form_post(self, client,user_db_session): + """Test posting with a wrong form.""" + response = client.post("/users", json={ + 'uid': '12', + 'is_student': True, # wrong field name + 'is_admin': False + }) + assert response.status_code == 400 + + 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 + }) + assert response.status_code == 415 + + def test_get_all_users(self, client,user_db_session): + """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) + + def test_get_one_user(self, client,user_db_session): + """Test getting a single user.""" + response = client.get("users/u_get") + assert response.status_code == 200 + assert response.json["data"] == { + 'uid': 'u_get', + 'is_teacher': True, + 'is_admin': False + } + + def test_patch_user(self, client, user_db_session): + """Test updating a user.""" + response = client.patch("/users/pat", json={ + 'is_teacher': False, + 'is_admin': True + }) + assert response.status_code == 200 + assert response.json["message"] == "User updated successfully!" + + def test_patch_non_existent(self, client,user_db_session): + """Test updating a non-existent user.""" + response = client.patch("/users/non", json={ + 'is_teacher': False, + 'is_admin': True + }) + assert response.status_code == 404 + + def test_patch_non_json(self, client,user_db_session): + """Test sending a non-JSON patch request.""" + response = client.post("/users", data={ + 'uid': '12', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 415 + + def test_get_users_with_query(self, client, user_db_session): + """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") + assert response.status_code == 200 + + # Check that the response contains only the user that matches the query + users = response.json["data"] + assert len(users) == 1 + assert users[0]["uid"] == "query_user" + assert users[0]["is_admin"] is True + assert users[0]["is_teacher"] is False