diff --git a/backend/project/__init__.py b/backend/project/__init__.py index b970f5e1..33450700 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from .db_in import db from .endpoints.index.index import index_bp +from .endpoints.projects.project_endpoint import project_bp from .endpoints.courses import courses_bp @@ -18,6 +19,7 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(project_bp) app.register_blueprint(courses_bp) return app diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index ec8fb29e..4152afb6 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -42,7 +42,192 @@ ] }, "paths": { - + "/projects": { + "get": { + "description": "Returns all projects from the database that the user has access to", + "responses": { + "200": { + "description": "A list of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "project_id": { + "type": "int" + }, + "descriptions": { + "type": "string" + }, + "title": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "post": { + "description": "Upload a new project", + "responses": { + "201": { + "description": "Uploaded a new project succesfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + } + } + } + }, + "/projects/{id}": { + "get": { + "description": "Return a project with corresponding id", + "responses": { + "200": { + "description": "A project with corresponding id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "archieved": { + "type": "bool" + }, + "assignment_file": { + "type": "string" + }, + "course_id": { + "type": "int" + }, + "deadline": { + "type": "date" + }, + "descriptions": { + "type": "array", + "items": { + "description": "string" + } + }, + "project_id": { + "type": "int" + }, + "regex_expressions": { + "type": "array", + "items": { + "regex": "string" + } + }, + "script_name": { + "type": "string" + }, + "test_path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "visible_for_students": { + "type": "bool" + } + } + } + } + } + }, + "404": { + "description": "An id that doesn't correspond to an existing project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "patch": { + "description": "Patch certain fields of a project", + "responses": { + "200": { + "description": "Patched a project succesfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + }, + "404": { + "description": "Tried to patch a project that is not present", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "description": "Delete a project with given id", + "responses": { + "200": { + "description": "Removed a project succesfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + }, + "404": { + "description": "Tried to remove a project that is not present", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + } + } + } + }, "/courses": { "get": { "description": "Get a list of all courses.", @@ -85,7 +270,7 @@ } } } - } + } }, "parameters": [ { @@ -111,7 +296,7 @@ "schema": { "type": "string" } - } + } ] }, "post": { @@ -611,7 +796,7 @@ } }, "post":{ - "description": "Assign students to a course.", + "description": "Assign students to a course.", "parameters": [ { "name": "course_id", @@ -733,7 +918,7 @@ } } } - } + } }, "delete":{ "description": "Remove students from a course.", @@ -918,7 +1103,7 @@ } }, "post":{ - "description": "Assign admins to a course.", + "description": "Assign admins to a course.", "parameters": [ { "name": "course_id", @@ -1040,7 +1225,7 @@ } } } - } + } }, "delete":{ "description": "Remove an admin from a course.", @@ -1156,6 +1341,6 @@ } } } - + } } diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py new file mode 100644 index 00000000..87f61e69 --- /dev/null +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -0,0 +1,31 @@ +""" +Parser for the argument when posting or patching a project +""" + +from flask_restful import reqparse + +parser = reqparse.RequestParser() +parser.add_argument('title', type=str, help='Projects title') +parser.add_argument('descriptions', type=str, help='Projects description') +parser.add_argument('assignment_file', type=str, help='Projects assignment file') +parser.add_argument("deadline", type=str, help='Projects deadline') +parser.add_argument("course_id", type=str, help='Projects course_id') +parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students') +parser.add_argument("archieved", type=bool, help='Projects') +parser.add_argument("test_path", type=str, help='Projects test path') +parser.add_argument("script_name", type=str, help='Projects test script path') +parser.add_argument("regex_expressions", type=str, help='Projects regex expressions') + + +def parse_project_params(): + """ + Return a dict of every non None value in the param + """ + args = parser.parse_args() + result_dict = {} + + for key, value in args.items(): + if value is not None: + result_dict[key] = value + + return result_dict diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py new file mode 100644 index 00000000..88989247 --- /dev/null +++ b/backend/project/endpoints/projects/project_detail.py @@ -0,0 +1,117 @@ +""" +Module for project details page +for example /projects/1 if the project id of +the corresponding project is 1 +""" +from os import getenv +from dotenv import load_dotenv + +from flask import jsonify +from flask_restful import Resource, abort +from sqlalchemy import exc +from project.endpoints.projects.endpoint_parser import parse_project_params + +from project import db +from project.models.projects import Project + +load_dotenv() +API_URL = getenv('API_HOST') + +class ProjectDetail(Resource): + """ + Class for projects/id endpoints + Inherits from flask_restful.Resource class + for implementing get, delete and put methods + """ + + def abort_if_not_present(self, project): + """ + Check if the project exists in the database + and if not abort the request and give back a 404 not found + """ + if project is None: + abort(404) + + def get(self, project_id): + """ + Get method for listing a specific project + filtered by id of that specific project + the id fetched from the url with the reaparse + """ + + try: + # fetch the project with the id that is specified in the url + project = Project.query.filter_by(project_id=project_id).first() + self.abort_if_not_present(project) + + # return the fetched project and return 200 OK status + return { + "data": jsonify(project).json, + "url": f"{API_URL}/projects/{project_id}", + "message": "Got project successfully" + }, 200 + except exc.SQLAlchemyError: + return { + "message": "Internal server error", + "url": f"{API_URL}/projects/{project_id}" + }, 500 + + def patch(self, project_id): + """ + Update method for updating a specific project + filtered by id of that specific project + """ + + # get the project that need to be edited + project = Project.query.filter_by(project_id=project_id).first() + + # check which values are not None in the dict + # if it is not None it needs to be modified in the database + + # commit the changes and return the 200 OK code if it succeeds, else 500 + try: + var_dict = parse_project_params() + for key, value in var_dict.items(): + setattr(project, key, value) + db.session.commit() + # get the updated version + return { + "message": f"Succesfully changed project with id: {id}", + "url": f"{API_URL}/projects/{id}", + "data": project + }, 200 + except exc.SQLAlchemyError: + db.session.rollback() + return { + "message": f"Something unexpected happenend when trying to edit project {id}", + "url": f"{API_URL}/projects/{id}" + }, 500 + + def delete(self, project_id): + """ + Delete a project and all of its submissions in cascade + done by project id + """ + + # fetch the project that needs to be removed + deleted_project = Project.query.filter_by(project_id=project_id).first() + + # check if its an existing one + self.abort_if_not_present(deleted_project) + + # if it exists delete it and commit the changes in the database + try: + db.session.delete(deleted_project) + db.session.commit() + + # return 200 if content is deleted succesfully + return { + "message": f"Project with id: {id} deleted successfully", + "url": f"{API_URL}/projects/{id} deleted successfully!", + "data": deleted_project + }, 200 + except exc.SQLAlchemyError: + return { + "message": f"Something unexpected happened when removing project {project_id}", + "url": f"{API_URL}/projects/{id}" + }, 500 diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py new file mode 100644 index 00000000..c996a514 --- /dev/null +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -0,0 +1,23 @@ +""" +Module for providing the blueprint to the api +of both routes +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.projects.projects import ProjectsEndpoint +from project.endpoints.projects.project_detail import ProjectDetail + +project_bp = Blueprint('project_endpoint', __name__) +project_endpoint = Api(project_bp) + +project_bp.add_url_rule( + '/projects', + view_func=ProjectsEndpoint.as_view('project_endpoint') +) + +project_bp.add_url_rule( + '/projects/', + view_func=ProjectDetail.as_view('project_detail') +) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py new file mode 100644 index 00000000..f444e283 --- /dev/null +++ b/backend/project/endpoints/projects/projects.py @@ -0,0 +1,94 @@ +""" +Module that implements the /projects endpoint of the API +""" +from os import getenv +from dotenv import load_dotenv + +from flask import jsonify +from flask_restful import Resource +from sqlalchemy import exc + + +from project import db +from project.models.projects import Project +from project.endpoints.projects.endpoint_parser import parse_project_params + +load_dotenv() +API_URL = getenv('API_HOST') + +class ProjectsEndpoint(Resource): + """ + Class for projects endpoints + Inherits from flask_restful.Resource class + for implementing get method + """ + + def get(self): + """ + Get method for listing all available projects + that are currently in the API + """ + try: + projects = Project.query.with_entities( + Project.project_id, + Project.title, + Project.descriptions + ).all() + + results = [{ + "project_id": row[0], + "title": row[1], + "descriptions": row[2] + } for row in projects] + + # return all valid entries for a project and return a 200 OK code + return { + "data": results, + "url": f"{API_URL}/projects", + "message": "Projects fetched successfully" + }, 200 + except exc.SQLAlchemyError: + return { + "message": "Something unexpected happenend when trying to get the projects", + "url": f"{API_URL}/projects" + }, 500 + + def post(self): + """ + Post functionality for project + using flask_restfull parse lib + """ + args = parse_project_params() + + # create a new project object to add in the API later + new_project = Project( + title=args['title'], + descriptions=args['descriptions'], + assignment_file=args['assignment_file'], + deadline=args['deadline'], + course_id=args['course_id'], + visible_for_students=args['visible_for_students'], + archieved=args['archieved'], + test_path=args['test_path'], + script_name=args['script_name'], + regex_expressions=args['regex_expressions'] + ) + + # add the new project to the database and commit the changes + + try: + db.session.add(new_project) + db.session.commit() + new_project_json = jsonify(new_project).json + + return { + "url": f"{API_URL}/projects/{new_project_json['project_id']}", + "message": "Project posted successfully", + "data": new_project_json + }, 201 + except exc.SQLAlchemyError: + return ({ + "url": f"{API_URL}/projects", + "message": "Something unexpected happenend when trying to add a new project", + "data": jsonify(new_project).json + }, 500) diff --git a/backend/project/models/projects.py b/backend/project/models/projects.py index b31066d3..5171e1e6 100644 --- a/backend/project/models/projects.py +++ b/backend/project/models/projects.py @@ -1,8 +1,11 @@ """Model for projects""" +import dataclasses + from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project import db -class Project(db.Model): +@dataclasses.dataclass +class Project(db.Model): # pylint: disable=too-many-instance-attributes """This class describes the projects table, a projects has an id, a title, a description, an optional assignment file that can contain more explanation of the projects, @@ -10,17 +13,21 @@ class Project(db.Model): the course id of the course to which the project belongs, visible for students variable so a teacher can decide if the students can see it yet, archieved var so we can implement the archiving functionality, - a test path,script name and regex experssions for automated testing""" + a test path,script name and regex expressions for automated testing + + Pylint disable too many instance attributes because we can't reduce the amount + of fields of the model + """ __tablename__ = "projects" - project_id = Column(Integer, primary_key=True) - title = Column(String(50), nullable=False, unique=False) - descriptions = Column(Text, nullable=False) - assignment_file = Column(String(50)) - deadline = Column(DateTime(timezone=True)) - course_id = Column(Integer, ForeignKey("courses.course_id"), nullable=False) - visible_for_students = Column(Boolean, nullable=False) - archieved = Column(Boolean, nullable=False) - test_path = Column(String(50)) - script_name = Column(String(50)) - regex_expressions = Column(ARRAY(String(50))) + project_id: int = Column(Integer, primary_key=True) + title: str = Column(String(50), nullable=False, unique=False) + descriptions: str = Column(Text, nullable=False) + assignment_file: str = Column(String(50)) + deadline: str = Column(DateTime(timezone=True)) + course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) + visible_for_students: bool = Column(Boolean, nullable=False) + archieved: bool = Column(Boolean, nullable=False) + test_path: str = Column(String(50)) + script_name: str = Column(String(50)) + regex_expressions: list[str] = Column(ARRAY(String(50))) diff --git a/backend/project/sessionmaker.py b/backend/project/sessionmaker.py new file mode 100644 index 00000000..9fbf1cad --- /dev/null +++ b/backend/project/sessionmaker.py @@ -0,0 +1,18 @@ +"""initialise a datab session""" +from os import getenv +from dotenv import load_dotenv +from sqlalchemy import create_engine, URL +from sqlalchemy.orm import sessionmaker + +load_dotenv() + +url = URL.create( + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_DB") +) + +engine = create_engine(url) +Session = sessionmaker(bind=engine) diff --git a/backend/run_tests.sh b/backend/run_tests.sh old mode 100644 new mode 100755 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..148ef6f2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,18 @@ +"""root level fixtures""" +import pytest +from project.sessionmaker import engine, Session +from project import db + +@pytest.fixture +def db_session(): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) + session = Session() + yield session + session.rollback() + session.close() + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 4e787ad9..0e964c22 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,14 +1,67 @@ -"""Configuration for pytest, Flask, and the test client.""" +""" Configuration for pytest, Flask, and the test client.""" +from datetime import datetime import os import pytest from project.models.courses import Course from project.models.users import User +from project.models.projects import Project from project.models.course_relations import CourseStudent,CourseAdmin from project import create_app_with_db, db from project.db_in import url +@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) + return ad_teacher + + +@pytest.fixture +def course_ad(course_teacher_ad: User): + """A course for testing, with the course teacher as the teacher.""" + ad2 = Course(name="Ad2", teacher=course_teacher_ad.uid) + return ad2 + + +@pytest.fixture +def project(course): + """A project for testing, with the course as the course it belongs to""" + date = datetime(2024, 2, 25, 12, 0, 0) + project = Project( + title="Project", + descriptions="Test project", + course_id=course.course_id, + assignment_file="testfile", + deadline=date, + visible_for_students=True, + archieved=False, + test_path="testpad", + script_name="testscript", + regex_expressions='r' + ) + return project + + +@pytest.fixture +def project_json(project: Project): + """A function that return the json data of a project including the PK neede for testing""" + data = { + "title": project.title, + "descriptions": project.descriptions, + "assignment_file": project.assignment_file, + "deadline": project.deadline, + "course_id": project.course_id, + "visible_for_students": project.visible_for_students, + "archieved": project.archieved, + "test_path": project.test_path, + "script_name": project.script_name, + "regex_expressions": project.regex_expressions + } + return data + + @pytest.fixture def api_url(): """Get the API URL from the environment.""" diff --git a/backend/tests/endpoints/index_test.py b/backend/tests/endpoints/index_test.py index 5624bba7..8f3a5d4e 100644 --- a/backend/tests/endpoints/index_test.py +++ b/backend/tests/endpoints/index_test.py @@ -1,10 +1,12 @@ """Test the base routes of the application""" + def test_home(client): """Test whether the index page is accesible""" response = client.get("/") assert response.status_code == 200 + def test_openapi_spec(client): """Test whether the required fields of the openapi spec are present""" response = client.get("/") diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py new file mode 100644 index 00000000..1ebecce4 --- /dev/null +++ b/backend/tests/endpoints/project_test.py @@ -0,0 +1,92 @@ +"""Tests for project endpoints.""" +from project.models.projects import Project + +def test_projects_home(client): + """Test home project endpoint.""" + response = client.get("/projects") + assert response.status_code == 200 + + +def test_getting_all_projects(client): + """Test getting all projects""" + response = client.get("/projects") + assert response.status_code == 200 + assert isinstance(response.json['data'], list) + + +def test_post_project(db_session, client, course_ad, course_teacher_ad, project_json): + """Test posting a project to the database and testing if it's present""" + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + + project_json["course_id"] = course_ad.course_id + + # post the project + response = client.post("/projects", json=project_json) + assert response.status_code == 201 + + # check if the project with the id is present + project_id = response.json["data"]["project_id"] + response = client.get(f"/projects/{project_id}") + + assert response.status_code == 200 + + +def test_remove_project(db_session, client, course_ad, course_teacher_ad, project_json): + """Test removing a project to the datab and fetching it, testing if it's not present anymore""" + + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + + project_json["course_id"] = course_ad.course_id + + # post the project + response = client.post("/projects", json=project_json) + + # check if the project with the id is present + project_id = response.json["data"]["project_id"] + + response = client.delete(f"/projects/{project_id}") + assert response.status_code == 200 + + # check if the project isn't present anymore and the delete indeed went through + response = client.delete(f"/projects/{project_id}") + assert response.status_code == 404 + + +def test_patch_project(db_session, client, course_ad, course_teacher_ad, project): + """ + Test functionality of the PUT method for projects + """ + + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + + project.course_id = course_ad.course_id + + # post the project to edit + db_session.add(project) + db_session.commit() + project_id = project.project_id + + new_title = "patched title" + new_archieved = not project.archieved + + response = client.patch(f"/projects/{project_id}", json={ + "title": new_title, "archieved": new_archieved + }) + db_session.commit() + updated_project = db_session.get(Project, {"project_id": project.project_id}) + + assert response.status_code == 200 + assert updated_project.title == new_title + assert updated_project.archieved == new_archieved diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 7bd5e2a0..4ed9bdcf 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -6,7 +6,6 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import pytest -from project import db from project.models.courses import Course from project.models.course_relations import CourseAdmin, CourseStudent from project.models.projects import Project @@ -16,22 +15,6 @@ engine = create_engine(url) Session = sessionmaker(bind=engine) -@pytest.fixture -def db_session(): - """Create a new database session for a test. - After the test, all changes are rolled back and the session is closed.""" - db.metadata.create_all(engine) - session = Session() - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() - yield session - session.rollback() - session.close() - # Truncate all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() @pytest.fixture def valid_user():