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

Commit

Permalink
Merge pull request #88 from SELab-2/code-annotations
Browse files Browse the repository at this point in the history
Code annotations
  • Loading branch information
msathieu authored Mar 14, 2024
2 parents 859cfb0 + 05dc471 commit 7774133
Show file tree
Hide file tree
Showing 15 changed files with 86 additions and 8 deletions.
10 changes: 10 additions & 0 deletions backend/db/errors/database_errors.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
class ItemNotFoundError(Exception):
"""
The specified item was not found in the database.
"""
def __init__(self, message: str) -> None:
super().__init__(message)


class ActionAlreadyPerformedError(Exception):
"""
The specified action was already performed on the database once before
and may not be performed again as to keep consistency.
"""
def __init__(self, message: str) -> None:
super().__init__(message)


class NoSuchRelationError(Exception):
"""
There is no relation between the two specified elements in the database.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
19 changes: 18 additions & 1 deletion backend/db/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,32 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase

"""
Retrieve the variables needed for a connection to the database.
We use environment variables for the code to be more adaptable across different environments.
The second variable in os.getenv specifies the default value.
"""
# where the database is hosted
db_host = os.getenv("DB_HOST", "localhost")
# port number on which the database server is listening
db_port = os.getenv("DB_PORT", "5432")
# username for the database
db_user = os.getenv("DB_USERNAME", "postgres")
# password for the user
db_password = os.getenv("DB_PASSWORD", "postgres")
# name of the database
db_database = os.getenv("DB_DATABASE", "delphi")

# dialect+driver://username:password@host:port/database
DB_URI = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_database}"

# The engine manages database-operations.
# There is only one instance of the engine, specified here.
engine = create_engine(DB_URI)


class Base(DeclarativeBase):
pass
"""
This class is meant to be inherited from to define the database tables, see [db/models/models.py].
For usage, please check https://docs.sqlalchemy.org/en/20/orm/declarative_styles.html#using-a-declarative-base-class.
"""
14 changes: 12 additions & 2 deletions backend/db/models/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import abstractmethod
from dataclasses import dataclass
from dataclasses import dataclass # automatically add special methods as __init__() and __repr__()
from datetime import datetime
from typing import Generic, TypeVar

Expand All @@ -17,15 +17,25 @@
from domain.models.TeacherDataclass import TeacherDataclass
from domain.models.UserDataclass import UserDataclass

# Create a generic type variable bound to subclasses of BaseModel.
D = TypeVar("D", bound=BaseModel)


@dataclass()
class AbstractModel(Generic[D]):
"""
This class is meant to be inherited by the python classes for the database tables.
It makes sure that every child implements the to_domain_model function
and receives Pydantic data validation.
"""
@abstractmethod
def to_domain_model(self) -> D:
pass
"""
Change to an actual easy-to-use dataclass defined in [domain/models/*].
This prevents working with instances of SQLAlchemy's Base class.
"""

# See the EER diagram for a more visual representation.

@dataclass()
class User(Base, AbstractModel):
Expand Down
5 changes: 5 additions & 0 deletions backend/db/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

from db.extensions import engine

# Generator for db sessions. Check the [documentation.md] for the use of sessions.
SessionLocal: sessionmaker[Session] = sessionmaker(autocommit=False, autoflush=False, bind=engine)


def get_session() -> Generator[Session, None, None]:
"""
Returns a generator for session objects.
To be used as dependency injection.
"""
db = SessionLocal()
try:
yield db
Expand Down
10 changes: 5 additions & 5 deletions backend/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@

#### 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.
Check the README.md for installation.

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).
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/20/orm/declarative_styles.html#using-a-declarative-base-class).

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.

Expand Down Expand Up @@ -50,6 +48,8 @@ We use **FastAPI** as framework. FastAPI follows the OpenAPI Specification. Its

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].

For specific documentation about the API-endpoints, start the app and go to /api/docs.

## 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.
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 for the local version only.
3 changes: 3 additions & 0 deletions backend/domain/logic/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@


def create_admin(session: Session, name: str, email: str) -> AdminDataclass:
"""
This function is meant to create a new user that is an admin. It does not change the role of an existing user.
"""
new_user: User = User(name=name, email=email)
session.add(new_user)
session.commit()
Expand Down
8 changes: 8 additions & 0 deletions backend/domain/logic/basic_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
from db.errors.database_errors import ItemNotFoundError
from db.models.models import AbstractModel

# Create a generic type variable bound to subclasses of AbstractModel.
T = TypeVar("T", bound=AbstractModel)


def get(session: Session, object_type: type[T], ident: int) -> T:
"""
General function for retrieving a single object from the database.
The type of the object and its id as well a session object has to be provided.
"""
generic_object: T | None = session.get(object_type, ident)

if not generic_object:
Expand All @@ -20,4 +25,7 @@ def get(session: Session, object_type: type[T], ident: int) -> T:


def get_all(session: Session, object_type: type[T]) -> list[T]:
"""
General function for retrieving all objects of a certain type from the database.
"""
return list(session.scalars(select(object_type)).all())
3 changes: 3 additions & 0 deletions backend/domain/logic/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@


def create_group(session: Session, project_id: int) -> GroupDataclass:
"""
Create an empty group for a certain project.
"""
project: Project = get(session, Project, project_id)
new_group: Group = Group(project_id=project_id)
project.groups.append(new_group)
Expand Down
3 changes: 3 additions & 0 deletions backend/domain/logic/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ def create_project(
visible: bool,
max_students: int,
) -> ProjectDataclass:
"""
Create a project for a certain subject.
"""
subject: Subject = get(session, Subject, subject_id)

new_project: Project = Project(
Expand Down
3 changes: 3 additions & 0 deletions backend/domain/logic/student.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@


def create_student(session: Session, name: str, email: str) -> StudentDataclass:
"""
This function is meant to create a new user that is a student. It does not change the role of an existing user.
"""
new_user: User = User(name=name, email=email)
session.add(new_user)
session.commit()
Expand Down
3 changes: 3 additions & 0 deletions backend/domain/logic/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ def create_submission(
state: SubmissionState,
date_time: datetime,
) -> SubmissionDataclass:
"""
Create a submission for a certain project by a certain group.
"""
student: Student = get(session, Student, ident=student_id)
group: Group = get(session, Group, ident=group_id)

Expand Down
3 changes: 3 additions & 0 deletions backend/domain/logic/teacher.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@


def create_teacher(session: Session, name: str, email: str) -> TeacherDataclass:
"""
This function is meant to create a new user that is a teacher. It does not change the role of an existing user.
"""
new_user: User = User(name=name, email=email)
session.add(new_user)
session.commit()
Expand Down
3 changes: 3 additions & 0 deletions backend/domain/logic/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@


def convert_user(session: Session, user: UserDataclass) -> APIUser:
"""
Given a UserDataclass, check what roles that user has and fill those in to convert it to an APIUser.
"""
api_user = APIUser(id=user.id, name=user.name, email=user.email, roles=[])

if is_user_teacher(session, user.id):
Expand Down
3 changes: 3 additions & 0 deletions backend/domain/models/APIUser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@


class APIUser(BaseModel):
"""
Same as UserDataclass, but with the roles specified in a list.
"""
id: int
name: str
email: EmailStr
Expand Down
4 changes: 4 additions & 0 deletions backend/domain/models/UserDataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@


class UserDataclass(BaseModel):
"""
This user does not have any roles yet.
When the roles become specified, use the almost equivalent APIUser.
"""
id: int
name: str
email: EmailStr

0 comments on commit 7774133

Please sign in to comment.