Skip to content
This repository has been archived by the owner on Sep 27, 2024. It is now read-only.

Commit

Permalink
Merge branch 'main' into routeLoaderData
Browse files Browse the repository at this point in the history
  • Loading branch information
matt01y authored Mar 13, 2024
2 parents 455a882 + b69bffa commit c8bb02d
Show file tree
Hide file tree
Showing 16 changed files with 538 additions and 47 deletions.
55 changes: 55 additions & 0 deletions backend/documentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Documentation Backend

## 1) Database

#### I) setup

- step 1: Install and set up **PostgreSQL**. We refer to the [official documentation](https://www.postgresql.org/docs/16/admin.html).
- step 2: Start a PostgreSQL server.
- step 3: Create a database on this server.

We use **SQLAlchemy** to access this database in our backend app. SQLAlchemy allows us to start defining tables, performing queries, etc.. The setup of SQLAlchemy happens in [db/extensions.py]. Here, an SQLAlchemy engine is created. This engine is the component that manages connections to the database. A [database URI](https://docs.sqlalchemy.org/en/20/core/engines.html) is needed to create such an engine. Because we host the backend app and the database in the same place, we use localhost in the URI as default. This is not mandatory; the database and backend app are two seperate things.

For test purposes, mockup data is available in [fill_database_mock.py]. A visual representation of the database is also recommended (eg. [pgAdmin](https://www.pgadmin.org/)).

#### II) tables

Using our EER diagram, we now want to create the corresponding tables. We use SQLAlchemy's declarative base pattern. Start by defining a Base class. This class contains the necessary functionality to interact with the database. Next, we create a python class for each table we need, and make it inherit the Base class. We call this a model (see [db/models/models.py]). For specifics on how to define these models, see [this link](https://docs.sqlalchemy.org/en/13/orm/extensions/declarative/basic_use.html).

An important thing to notice in this file is that other than the Base class, all models also inherit a class named AbstractModel. It makes sure that each model implements *to_domain_model*. We will come back to this function later on.


#### III) sessions

A SQLAlchemy session object provides an extra abstract layer to the engine. We will use these session objects in the logic part of our domain layer (see later).

> Using SQLAlchemy session objects simplifies database interactions by encapsulating transactions, providing features such as identity management, automatic transaction management, and a unit of work pattern, promoting cleaner, more maintainable code. \
\- ChatGPT

In [db/sessions.py], we define a generator for these session objects using our engine.

## 2) Domain layer

#### I) operations
in [domain/logic/] we define the actual backend functionality. Examples are *get_subjects_of_teacher* or *create_submission*. Some things to notice is that every function needs a session object, and that we manually commit changes. In [domain/logic/basic_operations.py], we define an abstract *get* and *get_all*, as these type of operations happen a lot. This *get* is of course not the same as a get request to the API. Three main errors that we provide manual coverage for are ItemNotFoundError, ActionAlreadyPerformedError and NoSuchRelationError (see [db/errors/]).

#### II) dataclasses

Now is a good time to explain the function *to_domain_model* from earlier. When we call a logic function, we don't want to return an instance of the Base class from SQLAlchemy. Instead we want to return very universal objects with that correspond one-to-one with an entity + attribute from our EER diagram. That's what a dataclass is. They are defined in [domain/models/].

> In our code, we use the name dataclass for two seperate things. The first is the @dataclass tag from the standard python library [dataclasses](https://docs.python.org/3/library/dataclasses.html). The other is the domain layer object just explained like SubmissionDataclass and TeacherDataclass.
These universal dataclasses inherit the **Pydantic** BaseModel. It allows for automatic data validation, JSON schema generation and more. More information on Pydantic can be found [here](https://docs.pydantic.dev/latest/why/).

## 3) API

We use **FastAPI** as framework. FastAPI follows the OpenAPI Specification. Its idea is to, in its turn, specify a REST API with a YAML document. This document can be used to generate documentation and methods for API endpoints. In every file in [/routes/], we can see a FastAPI router defined that represents some routes that are logically grouped together.

> A route is a URL pattern that directs HTTP requests to specific handlers or controllers in a web application, defining how requests are processed and responses are generated. It plays a crucial role in organizing the flow of data within the application. \
\- ChatGPT

Every route recieves a session object as a dependency injection, to forward to the corresponding logic operation. Dependencies are components that need to be executed before the route operation function is called. We let FastAPI handle this for us. Other than a database connection through the session object, we sometimes also inject some authentication/authorization logic (see [routes/dependencies/role_dependencies.py]) with corresponding errors in [routes/errors/authentication.py].

## 4) Running the app

We start by defining app = FastAPI() in [app.py]. Next, we add our routers from the previous section. We also add some exception handlers using the corresponding tag. FastAPI calls these handlers for us if needed. this way, we only have to return a corresponding JSONResponse. Finally, we start the app using a **uvicorn** server. This is the standard for FastApi. "app:app" specifies the location of the FastApi object. The first "app" refers to the module (i.e., app.py), and the second "app" refers to the variable (i.e., the FastAPI application object). By default, Uvicorn will run on the localhost port 8000. Another thing to note in this file is that we provide [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) functionality in production.
6 changes: 5 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ ignore = [
"PLR0913", # Too many arguments in function
"FBT001", # Boolean-typed positional argument in function dfinition
"FBT002",
"B008"
"B008",
"PT009",
"PT027",
"FBT003",
"DTZ005",
]

# Allow fix for all enabled rules (when `--fix`) is provided.
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ email_validator==2.1.1
fastapi==0.110.0
greenlet==3.0.3
h11==0.14.0
httpx==0.27.0
idna==3.6
nodeenv==1.8.0
pre-commit==3.6.2
Expand Down
27 changes: 0 additions & 27 deletions backend/tests/teachers_test.py

This file was deleted.

36 changes: 36 additions & 0 deletions backend/tests/test_edge_cases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# test_edge_cases.py
import unittest
from datetime import datetime

from test_main import SessionLocal, test_engine

from db.errors.database_errors import ItemNotFoundError
from db.extensions import Base
from domain.logic import group, student, submission
from domain.models.SubmissionDataclass import SubmissionState


class TestEdgeCases(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.drop_all(test_engine)
Base.metadata.create_all(test_engine)
self.session = SessionLocal()

def tearDown(self) -> None:
self.session.rollback()
self.session.close()

def test_add_student_to_non_existent_group(self) -> None:
stud = student.create_student(self.session, "Test Student", "[email protected]")
with self.assertRaises(ItemNotFoundError):
group.add_student_to_group(self.session, stud.id, 999)

def test_create_submission_for_non_existent_project(self) -> None:
stud = student.create_student(self.session, "Test Student", "[email protected]")
with self.assertRaises(ItemNotFoundError):
submission.create_submission(self.session, stud.id, 999, "Test Message", SubmissionState.Pending,
datetime.now())


if __name__ == "__main__":
unittest.main()
6 changes: 6 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

DB_TEST_URI = "postgresql://postgres:postgres@localhost:5432/delphi-test"
test_engine = create_engine(DB_TEST_URI)
SessionLocal = sessionmaker(autocommit=False, bind=test_engine)
84 changes: 84 additions & 0 deletions backend/tests/test_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# test_project.py
import unittest
from datetime import datetime

from test_main import SessionLocal, test_engine

from db.extensions import Base
from domain.logic.project import (
create_project,
get_all_projects,
get_project,
get_projects_of_student,
get_projects_of_subject,
get_projects_of_teacher,
)
from domain.logic.student import create_student
from domain.logic.subject import add_student_to_subject, add_teacher_to_subject, create_subject
from domain.logic.teacher import create_teacher


class TestProject(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.drop_all(test_engine)
Base.metadata.create_all(test_engine)
self.session = SessionLocal()

def tearDown(self) -> None:
self.session.rollback()
self.session.close()

def test_create_and_get_project(self) -> None:
subject = create_subject(self.session, "Test Subject")
project = create_project(self.session, subject.id, "Test Project", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
retrieved_project = get_project(self.session, project.id)
self.assertEqual(project.id, retrieved_project.id)

def test_get_all_projects(self) -> None:
subject1 = create_subject(self.session, "Test Subject 1")
subject2 = create_subject(self.session, "Test Subject 2")
create_project(self.session, subject1.id, "Test Project 1", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
create_project(self.session, subject2.id, "Test Project 2", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
self.assertEqual(len(get_all_projects(self.session)), 2)

def test_get_projects_of_subject(self) -> None:
subject = create_subject(self.session, "Test Subject")
create_project(self.session, subject.id, "Test Project 1", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
create_project(self.session, subject.id, "Test Project 2", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
projects_of_subject = get_projects_of_subject(self.session, subject.id)
self.assertEqual(len(projects_of_subject), 2)

def test_get_projects_of_student(self) -> None:
student = create_student(self.session, "Test Student", "[email protected]")
subject1 = create_subject(self.session, "Test Subject 1")
subject2 = create_subject(self.session, "Test Subject 2")
add_student_to_subject(self.session, student.id, subject1.id)
add_student_to_subject(self.session, student.id, subject2.id)
create_project(self.session, subject1.id, "Test Project 1", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
create_project(self.session, subject2.id, "Test Project 2", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
projects_of_student = get_projects_of_student(self.session, student.id)
self.assertEqual(len(projects_of_student), 2)

def test_get_projects_of_teacher(self) -> None:
teacher = create_teacher(self.session, "Test Teacher", "[email protected]")
subject1 = create_subject(self.session, "Test Subject 1")
subject2 = create_subject(self.session, "Test Subject 2")
add_teacher_to_subject(self.session, teacher.id, subject1.id)
add_teacher_to_subject(self.session, teacher.id, subject2.id)
create_project(self.session, subject1.id, "Test Project 1", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
create_project(self.session, subject2.id, "Test Project 2", datetime.now(), False, "Test Description",
"Test Requirements", True, 2)
projects_of_teacher = get_projects_of_teacher(self.session, teacher.id)
self.assertEqual(len(projects_of_teacher), 2)


if __name__ == "__main__":
unittest.main()
57 changes: 57 additions & 0 deletions backend/tests/test_stress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# test_stress.py
import unittest
from datetime import datetime

from test_main import SessionLocal, test_engine

from db.extensions import Base
from domain.logic import admin, group, project, student, subject, submission, teacher
from domain.models.SubmissionDataclass import SubmissionState


class TestStress(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.drop_all(test_engine)
Base.metadata.create_all(test_engine)
self.session = SessionLocal()

def tearDown(self) -> None:
self.session.rollback()
self.session.close()

def test_stress(self) -> None:
# Create multiple instances of each entity
for i in range(100):
stud = student.create_student(self.session, f"Test Student {i}", f"teststudent{i}@gmail.com")
subj = subject.create_subject(self.session, f"Test Subject {i}")
proj = project.create_project(self.session, subj.id, f"Test Project {i}", datetime.now(), False,
"Test Description",
"Test Requirements", True, 2)
grp = group.create_group(self.session, proj.id)
subm = submission.create_submission(self.session, stud.id, grp.id, "Test Message", SubmissionState.Pending,
datetime.now())
teach = teacher.create_teacher(self.session, f"Test Teacher {i}", f"testteacher{i}@gmail.com")
adm = admin.create_admin(self.session, f"Test Admin {i}", f"testadmin{i}@gmail.com")

# Perform operations on the entities
subject.add_student_to_subject(self.session, stud.id, subj.id)
group.add_student_to_group(self.session, stud.id, grp.id)
subject.add_teacher_to_subject(self.session, teach.id, subj.id)

# Assert the expected outcomes
self.assertEqual(student.get_student(self.session, stud.id).id, stud.id)
self.assertEqual(subject.get_subject(self.session, subj.id).id, subj.id)
self.assertEqual(project.get_project(self.session, proj.id).id, proj.id)
self.assertEqual(group.get_group(self.session, grp.id).id, grp.id)
self.assertEqual(submission.get_submission(self.session, subm.id).id, subm.id)
self.assertEqual(teacher.get_teacher(self.session, teach.id).id, teach.id)
self.assertEqual(admin.get_admin(self.session, adm.id).id, adm.id)

# Checks outside the loop
self.assertEqual(len(student.get_all_students(self.session)), 100)
self.assertEqual(len(subject.get_all_subjects(self.session)), 100)
self.assertEqual(len(project.get_all_projects(self.session)), 100)
self.assertEqual(len(group.get_all_groups(self.session)), 100)
self.assertEqual(len(submission.get_all_submissions(self.session)), 100)
self.assertEqual(len(teacher.get_all_teachers(self.session)), 100)
self.assertEqual(len(admin.get_all_admins(self.session)), 100)
40 changes: 40 additions & 0 deletions backend/tests/test_student.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# test_student.py
import unittest

from test_main import SessionLocal, test_engine

from db.extensions import Base
from domain.logic.student import create_student, get_all_students, get_student
from domain.logic.subject import add_student_to_subject, create_subject, get_subjects_of_student


class TestStudent(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.drop_all(test_engine)
Base.metadata.create_all(test_engine)
self.session = SessionLocal()

def tearDown(self) -> None:
self.session.rollback()
self.session.close()

def test_create_and_get_student(self) -> None:
student = create_student(self.session, "Test Student", "[email protected]")
retrieved_student = get_student(self.session, student.id)
self.assertEqual(student.id, retrieved_student.id)

def test_get_all_students(self) -> None:
create_student(self.session, "Test Student 1", "[email protected]")
create_student(self.session, "Test Student 2", "[email protected]")
self.assertEqual(len(get_all_students(self.session)), 2)

def test_add_student_to_subject(self) -> None:
student = create_student(self.session, "Test Student", "[email protected]")
subject = create_subject(self.session, "Test Subject")
add_student_to_subject(self.session, student.id, subject.id)
subjects_of_student = get_subjects_of_student(self.session, student.id)
self.assertIn(subject.id, [subject.id for subject in subjects_of_student])


if __name__ == "__main__":
unittest.main()
56 changes: 56 additions & 0 deletions backend/tests/test_subject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# test_subject.py
import unittest

from test_main import SessionLocal, test_engine

from db.extensions import Base
from domain.logic.student import create_student
from domain.logic.subject import (
add_student_to_subject,
add_teacher_to_subject,
create_subject,
get_all_subjects,
get_subject,
get_subjects_of_student,
get_subjects_of_teacher,
)
from domain.logic.teacher import create_teacher


class TestSubject(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.drop_all(test_engine)
Base.metadata.create_all(test_engine)
self.session = SessionLocal()

def tearDown(self) -> None:
self.session.rollback()
self.session.close()

def test_create_and_get_subject(self) -> None:
subject = create_subject(self.session, "Test Subject")
retrieved_subject = get_subject(self.session, subject.id)
self.assertEqual(subject.id, retrieved_subject.id)

def test_get_all_subjects(self) -> None:
create_subject(self.session, "Test Subject 1")
create_subject(self.session, "Test Subject 2")
self.assertEqual(len(get_all_subjects(self.session)), 2)

def test_add_student_to_subject(self) -> None:
student = create_student(self.session, "Test Student", "[email protected]")
subject = create_subject(self.session, "Test Subject")
add_student_to_subject(self.session, student.id, subject.id)
subjects_of_student = get_subjects_of_student(self.session, student.id)
self.assertIn(subject.id, [subject.id for subject in subjects_of_student])

def test_add_teacher_to_subject(self) -> None:
teacher = create_teacher(self.session, "Test Teacher", "[email protected]")
subject = create_subject(self.session, "Test Subject")
add_teacher_to_subject(self.session, teacher.id, subject.id)
subjects_of_teacher = get_subjects_of_teacher(self.session, teacher.id)
self.assertIn(subject.id, [subject.id for subject in subjects_of_teacher])


if __name__ == "__main__":
unittest.main()
Loading

0 comments on commit c8bb02d

Please sign in to comment.