diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 33b50ed7..6666f63f 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -13,3 +13,5 @@ jobs: run: pip install -r backend/requirements.txt - name: Lint code run: ruff check backend + - name: Run Pyright + run: pyright diff --git a/backend/db/errors/database_errors.py b/backend/db/errors/database_errors.py index 1b338da7..9fa9e114 100644 --- a/backend/db/errors/database_errors.py +++ b/backend/db/errors/database_errors.py @@ -1,4 +1,4 @@ class ItemNotFoundError(Exception): - def __init__(self, message: str): + def __init__(self, message: str) -> None: super().__init__(message) diff --git a/backend/db/implementation/SqlLesgeverDAO.py b/backend/db/implementation/SqlLesgeverDAO.py index 52581633..6bef5470 100644 --- a/backend/db/implementation/SqlLesgeverDAO.py +++ b/backend/db/implementation/SqlLesgeverDAO.py @@ -6,11 +6,12 @@ class SqlTeacherDAO(TeacherDAO): - def get_teacher(self, ident: int): - teacher: Teacher = Teacher.query.get(ident=ident) + def get_teacher(self, ident: int) -> TeacherDataclass: + teacher: Teacher | None = Teacher.query.get(ident=ident) if not teacher: - raise ItemNotFoundError("TeacherDataclass with given id not found.") + msg = f"Teacher with id {ident} not found" + raise ItemNotFoundError(msg) return teacher.to_domain_model() @@ -18,8 +19,8 @@ def get_all_teachers(self) -> list[TeacherDataclass]: teachers: list[Teacher] = Teacher.query.all() return [lesgever.to_domain_model() for lesgever in teachers] - def create_teacher(self, teacher: TeacherDataclass): - new_teacher = Teacher(name=teacher.name) + def create_teacher(self, teacher: TeacherDataclass) -> None: + new_teacher = Teacher(name=teacher.name, email=teacher.email) db.session.add(new_teacher) db.session.commit() diff --git a/backend/db/implementation/SqlVakDAO.py b/backend/db/implementation/SqlVakDAO.py index ea059734..b841634c 100644 --- a/backend/db/implementation/SqlVakDAO.py +++ b/backend/db/implementation/SqlVakDAO.py @@ -6,31 +6,29 @@ class SqlSubjectDAO(SubjectDAO): - def create_subject(self, subject: SubjectDataclass, teacher_id: int): - teacher = Teacher.query.get(teacher_id) + def create_subject(self, subject: SubjectDataclass) -> None: - if not teacher: - raise ItemNotFoundError(f"De teacher met id {teacher_id} kon niet in de databank gevonden worden") - - new_subject = Subject(name=subject.name, teacher=teacher) + new_subject = Subject(name=subject.name) db.session.add(new_subject) db.session.commit() subject.id = new_subject.id - def get_subject(self, teacher_id: int): - subject = Subject.query.get(teacher_id) + def get_subject(self, subject_id: int) -> SubjectDataclass: + subject = Subject.query.get(subject_id) if not subject: - raise ItemNotFoundError(f"De lesgever met id {teacher_id} kon niet in de databank gevonden worden") + msg = f"Het vak met id {subject_id} kon niet in de databank gevonden worden" + raise ItemNotFoundError(msg) return subject.to_domain_model() def get_subjects(self, teacher_id: int) -> list[SubjectDataclass]: - teacher: Teacher = Teacher.query.get(ident=teacher_id) + teacher: Teacher | None = Teacher.query.get(ident=teacher_id) if not teacher: - raise ItemNotFoundError(f"De teacher met id {teacher_id} kon niet in de databank gevonden worden") + msg = f"De teacher met id {teacher_id} kon niet in de databank gevonden worden" + raise ItemNotFoundError(msg) subjects: list[Subject] = teacher.subjects return [vak.to_domain_model() for vak in subjects] diff --git a/backend/db/interface/SubjectDAO.py b/backend/db/interface/SubjectDAO.py index 9c5c5015..bde7c4a7 100644 --- a/backend/db/interface/SubjectDAO.py +++ b/backend/db/interface/SubjectDAO.py @@ -5,7 +5,7 @@ class SubjectDAO(ABC): @abstractmethod - def create_subject(self, subject: SubjectDataclass, teacher_id: int): + def create_subject(self, subject: SubjectDataclass) -> None: """ Creëert een nieuw SubjectDataclass in de database en associeert het met een TeacherDataclass. @@ -13,18 +13,18 @@ def create_subject(self, subject: SubjectDataclass, teacher_id: int): :param teacher_id: De identificatie van de TeacherDataclass waarmee het SubjectDataclass geassocieerd wordt. :raises: ItemNotFoundException: Als er geen TeacherDataclass met de opgegeven `teacher_id` in de database is. """ - raise NotImplementedError() + raise NotImplementedError @abstractmethod - def get_subject(self, teacher_id: int): + def get_subject(self, subject_id: int) -> SubjectDataclass: """ Haalt een SubjectDataclass op aan de hand van zijn identificatie. - :param teacher_id: De identificatie van het op te halen SubjectDataclass. + :param subject_id: De identificatie van het op te halen SubjectDataclass. :raises ItemNotFoundException: Als er geen SubjectDataclass met de opgegeven `ident` in de database bestaat. :returns: De domeinmodel-instantie van het opgehaalde SubjectDataclass. """ - raise NotImplementedError() + raise NotImplementedError @abstractmethod def get_subjects(self, teacher_id: int) -> list[SubjectDataclass]: @@ -34,4 +34,4 @@ def get_subjects(self, teacher_id: int) -> list[SubjectDataclass]: :param teacher_id: De teacher waarvan de subjects opgehaald moeten worden. :return: Een lijst van subjects die door de gegeven teacher worden gegeven. """ - raise NotImplementedError() + raise NotImplementedError diff --git a/backend/db/interface/TeacherDAO.py b/backend/db/interface/TeacherDAO.py index 25bf91da..333822fe 100644 --- a/backend/db/interface/TeacherDAO.py +++ b/backend/db/interface/TeacherDAO.py @@ -13,7 +13,7 @@ def get_teacher(self, ident: int) -> TeacherDataclass: :return: De teacher die overeenkomt met de gegeven id. :raises ItemNotFoundException: Als geen teacher met het gegeven id gevonden werd. """ - raise NotImplementedError() + raise NotImplementedError @abstractmethod def get_all_teachers(self) -> list[TeacherDataclass]: @@ -22,13 +22,13 @@ def get_all_teachers(self) -> list[TeacherDataclass]: :return: Een lijst van alle lesgevers. """ - raise NotImplementedError() + raise NotImplementedError @abstractmethod - def create_teacher(self, teacher: TeacherDataclass): + def create_teacher(self, teacher: TeacherDataclass) -> None: """ Maakt een nieuwe teacher aan. :param teacher: De teacher die aangemaakt moet worden. """ - raise NotImplementedError() + raise NotImplementedError diff --git a/backend/db/models/models.py b/backend/db/models/models.py index 85174c52..8aa1425c 100644 --- a/backend/db/models/models.py +++ b/backend/db/models/models.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import datetime from sqlalchemy import Column, ForeignKey, Table @@ -14,15 +15,17 @@ from domain.models.UserDataclass import UserDataclass +@dataclass() class User(db.Model): - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] email: Mapped[str] + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) def to_domain_model(self) -> UserDataclass: return UserDataclass(id=self.id, name=self.name, email=self.email) +@dataclass() class Admin(User): id: Mapped[int] = mapped_column(ForeignKey(User.id), primary_key=True) @@ -50,6 +53,7 @@ def to_domain_model(self) -> AdminDataclass: ) +@dataclass() class Teacher(User): id: Mapped[int] = mapped_column(ForeignKey(User.id), primary_key=True) subjects: Mapped[list["Subject"]] = relationship(secondary=teachers_subjects, back_populates="teachers") @@ -58,6 +62,7 @@ def to_domain_model(self) -> TeacherDataclass: return TeacherDataclass(id=self.id, name=self.name, email=self.email) +@dataclass() class Student(User): id: Mapped[int] = mapped_column(ForeignKey(User.id), primary_key=True) subjects: Mapped[list["Subject"]] = relationship(secondary=students_subjects, back_populates="students") @@ -68,9 +73,10 @@ def to_domain_model(self) -> StudentDataclass: return StudentDataclass(id=self.id, name=self.name, email=self.email) +@dataclass() class Subject(db.Model): - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) teachers: Mapped[list[Teacher]] = relationship(secondary=teachers_subjects, back_populates="subjects") students: Mapped[list[Student]] = relationship(secondary=students_subjects, back_populates="subjects") projects: Mapped[list["Project"]] = relationship(back_populates="subject") @@ -79,14 +85,15 @@ def to_domain_model(self) -> SubjectDataclass: return SubjectDataclass(id=self.id, name=self.name) +@dataclass() class Project(db.Model): - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] deadline: Mapped[datetime] archived: Mapped[bool] requirements: Mapped[str] visible: Mapped[bool] max_students: Mapped[int] + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) subject_id: Mapped[int] = mapped_column(ForeignKey(Subject.id)) subject: Mapped[Subject] = relationship(back_populates="projects") groups: Mapped[list["Group"]] = relationship(back_populates="project") @@ -104,6 +111,7 @@ def to_domain_model(self) -> ProjectDataclass: ) +@dataclass() class Group(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) project_id: Mapped[int] = mapped_column(ForeignKey(Project.id)) @@ -115,15 +123,16 @@ def to_domain_model(self) -> GroupDataclass: return GroupDataclass(id=self.id, project_id=self.project_id) +@dataclass() class Submission(db.Model): - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) date_time: Mapped[datetime] + state: Mapped[SubmissionState] + message: Mapped[str] + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) group_id: Mapped[int] = mapped_column(ForeignKey(Group.id)) group: Mapped[Group] = relationship(back_populates="submissions") student_id: Mapped[int] = mapped_column(ForeignKey(Student.id)) student: Mapped[Student] = relationship(back_populates="submissions") - state: Mapped[SubmissionState] - message: Mapped[str] def to_domain_model(self) -> SubmissionDataclass: return SubmissionDataclass( diff --git a/backend/domain/models/base_model.py b/backend/domain/models/base_model.py index 5f8dc6cc..9ab89131 100644 --- a/backend/domain/models/base_model.py +++ b/backend/domain/models/base_model.py @@ -1,9 +1,8 @@ import dataclasses -from abc import ABC from dataclasses import dataclass @dataclass() -class JsonRepresentable(ABC): +class JsonRepresentable: def to_dict(self) -> dict: return dataclasses.asdict(self) diff --git a/backend/domain/validation/SubjectValidator.py b/backend/domain/validation/SubjectValidator.py index 636417dd..88b2a892 100644 --- a/backend/domain/validation/SubjectValidator.py +++ b/backend/domain/validation/SubjectValidator.py @@ -1,18 +1,21 @@ -from domain.validation.ValidationResult import ValidationResult +from domain.validation.ValidationResult import ValidationError, ValidationResult, ValidationSuccess class SubjectValidator: @staticmethod - def validate(json_data: dict): - result = ValidationResult() + def validate(json_data: dict) -> ValidationResult: name = json_data.get("name") teacher_id = json_data.get("teacher_id") + errors: list[str] = [] if not name: - result.add_error("Veld 'name' ontbreekt.") + errors.append("Veld 'name' ontbreekt.") if not teacher_id: - result.add_error("Veld 'teacher_id' ontbreekt.") + errors.append("Veld 'teacher_id' ontbreekt.") - return result + if len(errors) > 0: + return ValidationError(errors) + + return ValidationSuccess() diff --git a/backend/domain/validation/TeacherValidator.py b/backend/domain/validation/TeacherValidator.py index a90afa6d..02d39b88 100644 --- a/backend/domain/validation/TeacherValidator.py +++ b/backend/domain/validation/TeacherValidator.py @@ -1,14 +1,17 @@ -from domain.validation.ValidationResult import ValidationResult +from domain.validation.ValidationResult import ValidationError, ValidationResult, ValidationSuccess class TeacherValidator: @staticmethod - def validate(json_data: dict): - result = ValidationResult() + def validate(json_data: dict) -> ValidationResult: name = json_data.get("name") + errors: list[str] = [] if not name: - result.add_error("Veld 'name' ontbreekt.") + errors.append("Veld 'name' ontbreekt.") - return result + if len(errors) > 0: + return ValidationError(errors) + + return ValidationSuccess() diff --git a/backend/domain/validation/ValidationResult.py b/backend/domain/validation/ValidationResult.py index ed238332..3ffe7142 100644 --- a/backend/domain/validation/ValidationResult.py +++ b/backend/domain/validation/ValidationResult.py @@ -1,11 +1,24 @@ -class ValidationResult: - def __init__(self, is_ok: bool = True, errors: list[str] | None = None): - self.is_ok = is_ok - self.errors = errors if errors is not None else [] +from abc import ABC, abstractmethod - def add_error(self, error: str): - self.is_ok = False - self.errors.append(error) - def __bool__(self): - return self.is_ok +class ValidationResult(ABC): + + errors: list[str] + + @abstractmethod + def __bool__(self) -> bool: + raise NotImplementedError + + +class ValidationSuccess(ValidationResult): + + def __bool__(self) -> bool: + return True + + +class ValidationError(ValidationResult): + def __init__(self, errors: list[str]) -> None: + self.errors = errors + + def __bool__(self) -> bool: + return False diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 00000000..47494028 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,125 @@ +[project] +name = "Delphi" +version = "0.1.0" +description = "A Dodona clone" +authors = [ + {name = "Mathieu Strypsteen", email = "mathieu.strypsteen@ugent.be"}, + {name = "Albéric Loos", email = "alberic.loos@ugent.be"}, + {name = "Emma Vandewalle", email = "emma.vandewalle@ugent.be"}, + {name = "Lukas Barragan Torres", email = "lukas.barragantorres@ugent.be"}, + {name = "Matthias Seghers", email = "matthias.seghers@ugent.be"}, + {name = "Robbe Van de Keere", email = "robbe.vandekeere@ugent.be"}, + {name = "Ruben Vandamme", email = "ruben.vandamme@ugent.be"}, + {name = "Stef Ossé", email = "stef.osse@ugent.be"} +] +python = ">=3.12" + +[tool.pyright] +exclude = [ + ".venv" +] + +[tool.ruff] +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. +line-length = 120 +indent-width = 4 + +# Assume Python 3.12 +target-version = "py312" + + + + +[tool.ruff.lint] +select = [ + "ALL", + # "F", # Pyflakes + # "E", # Pycodestyle + # "W", # Warnings + # "I", # Isort (sorted imports) + # "N", # PEP8 + # "A", # Flake8-builtins + # "C4", # Comprehensions + # "PIE", # flake8 pie + # "Q", # Quotes + # "RET", # returns + # "SIM", # simplify + # "ARG", # unused arguments + # "ERA", # no commented out code + # "PL" # all pylint +] +ignore = [ + "D", # Docstrings + "ANN101", # type annotation for self + "S201", # Use of `debug=True` in Flask app detected + "INP001", # File `...` is part of an implicit namespace package + "S101", # is nodig voor de testen + "TCH001", # Move application import `...` into a type-checking block (dit is enkel voor performance) + "RUF009" # Do not perform function call `...` in dataclass defaults but needed for sql alchemy +] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + + + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 0e9c38d8..d10c6e2c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ ruff==0.2.2 SQLAlchemy==2.0.27 typing_extensions==4.9.0 Werkzeug==3.0.1 +pyright==1.1.351 \ No newline at end of file diff --git a/backend/routes/teachers.py b/backend/routes/teachers.py index d35d4737..f9ea522d 100644 --- a/backend/routes/teachers.py +++ b/backend/routes/teachers.py @@ -13,7 +13,7 @@ @teachers_blueprint.route("/teachers") -def get_teachers(): +def get_teachers() -> Response: dao: TeacherDAO = SqlTeacherDAO() teachers: list[TeacherDataclass] = dao.get_all_teachers() @@ -23,7 +23,7 @@ def get_teachers(): @teachers_blueprint.route("/teachers/") -def get_teacher(teacher_id): +def get_teacher(teacher_id: int) -> Response: dao: TeacherDAO = SqlTeacherDAO() teacher: TeacherDataclass = dao.get_teacher(teacher_id) @@ -33,19 +33,19 @@ def get_teacher(teacher_id): @teachers_blueprint.route("/teachers", methods=["POST"]) -def create_teacher(): +def create_teacher() -> Response: teacher_data: dict = request.get_json() if not teacher_data: - return json.dumps({"error": "Foute JSON of Content-Type"}), HTTPStatus.BAD_REQUEST + return Response(json.dumps({"error": "Foute JSON of Content-Type"}), status=HTTPStatus.BAD_REQUEST) validation_result: ValidationResult = TeacherValidator.validate(teacher_data) - if not validation_result.is_ok: - return json.dumps({"error": validation_result.errors}), HTTPStatus.BAD_REQUEST + if not validation_result: + return Response(json.dumps({"error": validation_result.errors}), status=HTTPStatus.BAD_REQUEST) dao: TeacherDAO = SqlTeacherDAO() lesgever = TeacherDataclass(**teacher_data) # Vul alle velden van het dataobject in met de json dao.create_teacher(lesgever) - return json.dumps(lesgever.to_dict()), HTTPStatus.CREATED + return Response(json.dumps(lesgever.to_dict()), status=HTTPStatus.CREATED) diff --git a/backend/ruff.toml b/backend/ruff.toml deleted file mode 100644 index 61f66dd2..00000000 --- a/backend/ruff.toml +++ /dev/null @@ -1,92 +0,0 @@ -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".ipynb_checkpoints", - ".mypy_cache", - ".nox", - ".pants.d", - ".pyenv", - ".pytest_cache", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - ".vscode", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "site-packages", - "venv", -] - -# Same as Black. -line-length = 120 -indent-width = 4 - -# Assume Python 3.8 -target-version = "py312" - -[lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or -# McCabe complexity (`C901`) by default. -select = [ - "F", # Pyflakes - "E", # Pycodestyle - "W", # Warnings - "I", # Isort (sorted imports) - "N", # PEP8 - "A", # Flake8-builtins - "C4", # Comprehensions - "PIE", # flake8 pie - "Q", # Quotes - "RET", # returns - "SIM", # simplify - "ARG", # unused arguments - "ERA", # no commented out code - "PL" # all pylint -] -ignore = [] - -# Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -[format] -# Like Black, use double quotes for strings. -quote-style = "double" - -# Like Black, indent with spaces, rather than tabs. -indent-style = "space" - -# Like Black, respect magic trailing commas. -skip-magic-trailing-comma = false - -# Like Black, automatically detect the appropriate line ending. -line-ending = "auto" - -# Enable auto-formatting of code examples in docstrings. Markdown, -# reStructuredText code/literal blocks and doctests are all supported. -# -# This is currently disabled by default, but it is planned for this -# to be opt-out in the future. -docstring-code-format = false - -# Set the line length limit used when formatting code snippets in -# docstrings. -# -# This only has an effect when the `docstring-code-format` setting is -# enabled. -docstring-code-line-length = "dynamic" diff --git a/backend/tests/teachers_test.py b/backend/tests/teachers_test.py index 2b4a14bb..960a98ab 100644 --- a/backend/tests/teachers_test.py +++ b/backend/tests/teachers_test.py @@ -6,19 +6,19 @@ class LesgeverTestCase(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.app = app.test_client() - self.app.testing = True - def test_create_teacher_bad_request(self): + + def test_create_teacher_bad_request(self) -> None: response = self.app.post("/teachers", data=json.dumps({}), content_type="application/json") - self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_code) - self.assertIn("error", json.loads(response.data)) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert "error" in json.loads(response.data) - def test_create_teacher_success(self): + def test_create_teacher_success(self) -> None: teacher_data = {"name": "Bart De Bruyn"} response = self.app.post("/teachers", data=json.dumps(teacher_data), content_type="application/json") - self.assertEqual(HTTPStatus.CREATED, response.status_code) + assert response.status_code == HTTPStatus.CREATED if __name__ == "__main__":