From 957514ef4b57860ea7f93d3dd91ee208fa6f8805 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Fri, 20 Mar 2026 14:41:16 +0000 Subject: [PATCH 01/26] Add models --- db_models/associated_img_model.py | 31 +++++++++++++++ db_models/course_document_model.py | 33 ++++++++++++++++ db_models/course_model.py | 50 ++++++++++++++++++++++++ db_models/program_model.py | 36 +++++++++++++++++ db_models/program_year_course_model.py | 18 +++++++++ db_models/program_year_model.py | 41 +++++++++++++++++++ db_models/specialisation_course_model.py | 20 ++++++++++ db_models/specialisation_model.py | 41 +++++++++++++++++++ helpers/constants.py | 21 ++++++++++ helpers/types.py | 3 ++ 10 files changed, 294 insertions(+) create mode 100644 db_models/associated_img_model.py create mode 100644 db_models/course_document_model.py create mode 100644 db_models/course_model.py create mode 100644 db_models/program_model.py create mode 100644 db_models/program_year_course_model.py create mode 100644 db_models/program_year_model.py create mode 100644 db_models/specialisation_course_model.py create mode 100644 db_models/specialisation_model.py diff --git a/db_models/associated_img_model.py b/db_models/associated_img_model.py new file mode 100644 index 0000000..6e0b4df --- /dev/null +++ b/db_models/associated_img_model.py @@ -0,0 +1,31 @@ +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import String + +from helpers.constants import MAX_PATH_LENGTH +from .base_model import BaseModel_DB +from sqlalchemy.orm import relationship, Mapped, mapped_column + +if TYPE_CHECKING: + from .program_model import Program_DB + from .program_year_model import ProgramYear_DB + from .course_model import Course_DB + from .specialisation_model import Specialisation_DB + + +class AssociatedImg_DB(BaseModel_DB): + __tablename__ = "associated_img_table" + + associated_image_id: Mapped[int] = mapped_column(primary_key=True, init=False) + + path: Mapped[str] = mapped_column(String(MAX_PATH_LENGTH)) + + program: Mapped[Optional["Program_DB"]] = relationship(back_populates="img", init=False, uselist=False) + + program_year: Mapped[Optional["ProgramYear_DB"]] = relationship(back_populates="img", init=False, uselist=False) + + course: Mapped[Optional["Course_DB"]] = relationship(back_populates="img", init=False, uselist=False) + + specialisation: Mapped[Optional["Specialisation_DB"]] = relationship( + back_populates="img", init=False, uselist=False + ) diff --git a/db_models/course_document_model.py b/db_models/course_document_model.py new file mode 100644 index 0000000..2cc842a --- /dev/null +++ b/db_models/course_document_model.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING, Optional +from sqlalchemy.orm import mapped_column, Mapped, relationship +from sqlalchemy import String, ForeignKey + +from helpers.constants import MAX_DOC_FILE_NAME, MAX_DOC_TITLE, MAX_COURSE_DOC_AUTHOR, MAX_COURSE_DOC_SUB_CATEGORY +from .base_model import BaseModel_DB +from helpers.db_util import created_at_column, latest_modified_column +from helpers.types import COURSE_DOCUMENT_CATEGORIES, datetime_utc + +if TYPE_CHECKING: + from .course_model import Course_DB + + +class CourseDocument_DB(BaseModel_DB): + __tablename__ = "course_document_table" + course_document_id: Mapped[int] = mapped_column(primary_key=True, init=False) + + title: Mapped[str] = mapped_column(String(MAX_DOC_TITLE), nullable=False) + file_name: Mapped[str] = mapped_column(String(MAX_DOC_FILE_NAME), nullable=False) + + course_id: Mapped[int] = mapped_column(ForeignKey("course_table.course_id")) + + course: Mapped["Course_DB"] = relationship(back_populates="documents", init=False) + + author: Mapped[str] = mapped_column(String(MAX_COURSE_DOC_AUTHOR), nullable=False) + + category: Mapped[COURSE_DOCUMENT_CATEGORIES] = mapped_column(default="Other") + + # Can be used to separate lecture notes from different authors or years, for example. + sub_category: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_DOC_SUB_CATEGORY), default=None) + + created_at: Mapped[datetime_utc] = created_at_column() + updated_at: Mapped[datetime_utc] = latest_modified_column() diff --git a/db_models/course_model.py b/db_models/course_model.py new file mode 100644 index 0000000..b5d8b50 --- /dev/null +++ b/db_models/course_model.py @@ -0,0 +1,50 @@ +from helpers.constants import MAX_COURSE_TITLE, MAX_COURSE_DESC, MAX_COURSE_CODE +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional +from sqlalchemy import String, ForeignKey +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy + +if TYPE_CHECKING: + from .associated_img_model import AssociatedImg_DB + from .program_year_model import ProgramYear_DB + from .program_year_course_model import ProgramYearCourse_DB + from .course_document_model import CourseDocument_DB + from .specialisation_course_model import SpecialisationCourse_DB + from .specialisation_model import Specialisation_DB + + +class Course_DB(BaseModel_DB): + __tablename__ = "course_table" + + course_id: Mapped[int] = mapped_column(primary_key=True, init=False) + + title: Mapped[str] = mapped_column(String(MAX_COURSE_TITLE)) + + course_code: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_CODE), default=None) + + description: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_DESC), default=None) + + program_year_courses: Mapped[list["ProgramYearCourse_DB"]] = relationship( + back_populates="course", cascade="all, delete-orphan", init=False + ) + + program_years: AssociationProxy[list["ProgramYear_DB"]] = association_proxy( + target_collection="program_year_courses", attr="program_year", init=False + ) + + specialisation_courses: Mapped[list["SpecialisationCourse_DB"]] = relationship( + back_populates="course", cascade="all, delete-orphan", init=False + ) + + specialisations: AssociationProxy[list["Specialisation_DB"]] = association_proxy( + target_collection="specialisation_courses", attr="specialisation", init=False + ) + + img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + + img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="course", init=False, uselist=False) + + documents: Mapped[list["CourseDocument_DB"]] = relationship( + back_populates="course", cascade="all, delete-orphan", init=False + ) diff --git a/db_models/program_model.py b/db_models/program_model.py new file mode 100644 index 0000000..fe25949 --- /dev/null +++ b/db_models/program_model.py @@ -0,0 +1,36 @@ +from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional +from sqlalchemy import ForeignKey, String + +if TYPE_CHECKING: + from .associated_img_model import AssociatedImg_DB + from .program_year_model import ProgramYear_DB + from .specialisation_model import Specialisation_DB + + +class Program_DB(BaseModel_DB): + __tablename__ = "program_table" + + program_id: Mapped[int] = mapped_column(primary_key=True, init=False) + + title_sv: Mapped[str] = mapped_column(String(MAX_PROGRAM_TITLE)) + + title_en: Mapped[str] = mapped_column(String(MAX_PROGRAM_TITLE)) + + description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_DESC), default=None) + + description_en: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_DESC), default=None) + + img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + + img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="program", init=False, uselist=False) + + program_years: Mapped[list["ProgramYear_DB"]] = relationship( + back_populates="program", cascade="all, delete-orphan", init=False + ) + + specialisations: Mapped[list["Specialisation_DB"]] = relationship( + back_populates="program", cascade="all, delete-orphan", init=False + ) diff --git a/db_models/program_year_course_model.py b/db_models/program_year_course_model.py new file mode 100644 index 0000000..a1a381b --- /dev/null +++ b/db_models/program_year_course_model.py @@ -0,0 +1,18 @@ +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING +from sqlalchemy import ForeignKey + +if TYPE_CHECKING: + from .program_year_model import ProgramYear_DB + from .course_model import Course_DB + + +class ProgramYearCourse_DB(BaseModel_DB): + __tablename__ = "program_year_course_table" + + program_year_id: Mapped[int] = mapped_column(ForeignKey("program_year_table.program_year_id"), primary_key=True) + course_id: Mapped[int] = mapped_column(ForeignKey("course_table.course_id"), primary_key=True) + + program_year: Mapped["ProgramYear_DB"] = relationship(back_populates="program_year_courses", init=False) + course: Mapped["Course_DB"] = relationship(back_populates="program_year_courses", init=False) diff --git a/db_models/program_year_model.py b/db_models/program_year_model.py new file mode 100644 index 0000000..c7a55d4 --- /dev/null +++ b/db_models/program_year_model.py @@ -0,0 +1,41 @@ +from helpers.constants import MAX_PROGRAM_YEAR_DESC, MAX_PROGRAM_YEAR_TITLE +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional +from sqlalchemy import String, ForeignKey +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy + +if TYPE_CHECKING: + from .associated_img_model import AssociatedImg_DB + from .program_model import Program_DB + from .program_year_course_model import ProgramYearCourse_DB + from .course_model import Course_DB + + +class ProgramYear_DB(BaseModel_DB): + __tablename__ = "program_year_table" + + program_year_id: Mapped[int] = mapped_column(primary_key=True, init=False) + + title_sv: Mapped[str] = mapped_column(String(MAX_PROGRAM_YEAR_TITLE)) + + title_en: Mapped[str] = mapped_column(String(MAX_PROGRAM_YEAR_TITLE)) + + program_id: Mapped[int] = mapped_column(ForeignKey("program_table.program_id")) + + program: Mapped["Program_DB"] = relationship(back_populates="program_years", init=False) + + description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_YEAR_DESC), default=None) + + description_en: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_YEAR_DESC), default=None) + + img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + + img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="program_year", init=False, uselist=False) + + program_year_courses: Mapped[list["ProgramYearCourse_DB"]] = relationship( + back_populates="program_year", cascade="all, delete-orphan", init=False + ) + courses: AssociationProxy[list["Course_DB"]] = association_proxy( + target_collection="program_year_courses", attr="course", init=False + ) diff --git a/db_models/specialisation_course_model.py b/db_models/specialisation_course_model.py new file mode 100644 index 0000000..459a113 --- /dev/null +++ b/db_models/specialisation_course_model.py @@ -0,0 +1,20 @@ +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING +from sqlalchemy import ForeignKey + +if TYPE_CHECKING: + from .specialisation_model import Specialisation_DB + from .course_model import Course_DB + + +class SpecialisationCourse_DB(BaseModel_DB): + __tablename__ = "specialisation_course_table" + + specialisation_id: Mapped[int] = mapped_column( + ForeignKey("specialisation_table.specialisation_id"), primary_key=True + ) + course_id: Mapped[int] = mapped_column(ForeignKey("course_table.course_id"), primary_key=True) + + specialisation: Mapped["Specialisation_DB"] = relationship(back_populates="specialisation_courses", init=False) + course: Mapped["Course_DB"] = relationship(back_populates="specialisation_courses", init=False) diff --git a/db_models/specialisation_model.py b/db_models/specialisation_model.py new file mode 100644 index 0000000..416da31 --- /dev/null +++ b/db_models/specialisation_model.py @@ -0,0 +1,41 @@ +from helpers.constants import MAX_SPECIALISATION_DESC, MAX_SPECIALISATION_TITLE +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional +from sqlalchemy import String, ForeignKey +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy + +if TYPE_CHECKING: + from .associated_img_model import AssociatedImg_DB + from .program_model import Program_DB + from .specialisation_course_model import SpecialisationCourse_DB + from .course_model import Course_DB + + +class Specialisation_DB(BaseModel_DB): + __tablename__ = "specialisation_table" + + specialisation_id: Mapped[int] = mapped_column(primary_key=True, init=False) + + title_sv: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE)) + + title_en: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE)) + + program_id: Mapped[int] = mapped_column(ForeignKey("program_table.program_id")) + + program: Mapped["Program_DB"] = relationship(back_populates="specialisations", init=False) + + description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_SPECIALISATION_DESC), default=None) + + description_en: Mapped[Optional[str]] = mapped_column(String(MAX_SPECIALISATION_DESC), default=None) + + img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + + img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="specialisation", init=False, uselist=False) + + specialisation_courses: Mapped[list["SpecialisationCourse_DB"]] = relationship( + back_populates="specialisation", cascade="all, delete-orphan", init=False + ) + courses: AssociationProxy[list["Course_DB"]] = association_proxy( + target_collection="specialisation_courses", attr="course", init=False + ) diff --git a/helpers/constants.py b/helpers/constants.py index 1251a26..b3d9c44 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -101,3 +101,24 @@ MAX_GUILD_MEETING_DATE_DESC = 500 MAX_GUILD_MEETING_DESC = 10000 MAX_GUILD_MEETING_TITLE = 200 + +# Program +MAX_PROGRAM_TITLE = 100 +MAX_PROGRAM_DESC = 10000 + +# Program Year +MAX_PROGRAM_YEAR_TITLE = 100 +MAX_PROGRAM_YEAR_DESC = 10000 + +# Specialisation +MAX_SPECIALISATION_TITLE = 100 +MAX_SPECIALISATION_DESC = 10000 + +# Course +MAX_COURSE_TITLE = 100 +MAX_COURSE_CODE = 20 +MAX_COURSE_DESC = 10000 + +# Course document +MAX_COURSE_DOC_AUTHOR = 50 +MAX_COURSE_DOC_SUB_CATEGORY = 50 diff --git a/helpers/types.py b/helpers/types.py index c4cbb0a..6d90863 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -108,3 +108,6 @@ def force_utc(date: datetime): ELECTION_ELECTORS = Literal["Guild", "Board", "Educational Council", "Board Intermediate", "Other"] ELECTION_SEMESTERS = Literal["HT", "VT", "Other"] + +# Course document categories, shown under different titles on the frontend +COURSE_DOCUMENT_CATEGORIES = Literal["Notes", "Summary", "Solutions", "Other"] From 6857953652c407da65bacd45fbd89849867e3cc4 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sat, 21 Mar 2026 10:49:17 +0000 Subject: [PATCH 02/26] Add preliminary schemas --- api_schemas/associated_img_schema.py | 11 +++++++ api_schemas/course_document_schema.py | 35 +++++++++++++++++++++ api_schemas/course_schema.py | 45 +++++++++++++++++++++++++++ api_schemas/program_schema.py | 34 ++++++++++++++++++++ api_schemas/program_year_schema.py | 35 +++++++++++++++++++++ api_schemas/specialisation_schema.py | 35 +++++++++++++++++++++ db_models/course_model.py | 6 +++- 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 api_schemas/associated_img_schema.py create mode 100644 api_schemas/course_document_schema.py create mode 100644 api_schemas/course_schema.py create mode 100644 api_schemas/program_schema.py create mode 100644 api_schemas/program_year_schema.py create mode 100644 api_schemas/specialisation_schema.py diff --git a/api_schemas/associated_img_schema.py b/api_schemas/associated_img_schema.py new file mode 100644 index 0000000..4b49de5 --- /dev/null +++ b/api_schemas/associated_img_schema.py @@ -0,0 +1,11 @@ +from fastapi import UploadFile +from api_schemas.base_schema import BaseSchema + + +class AssociatedImgRead(BaseSchema): + associated_image_id: int + path: str + + +class AssociatedImgCreate(BaseSchema): + file: UploadFile diff --git a/api_schemas/course_document_schema.py b/api_schemas/course_document_schema.py new file mode 100644 index 0000000..3912ebd --- /dev/null +++ b/api_schemas/course_document_schema.py @@ -0,0 +1,35 @@ +from typing import Annotated +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from helpers.constants import MAX_COURSE_DOC_AUTHOR, MAX_COURSE_DOC_SUB_CATEGORY, MAX_DOC_FILE_NAME, MAX_DOC_TITLE +from helpers.types import COURSE_DOCUMENT_CATEGORIES, datetime_utc + + +class CourseDocumentRead(BaseSchema): + course_document_id: int + title: str + file_name: str + course_id: int + author: str + category: COURSE_DOCUMENT_CATEGORIES + sub_category: str | None + created_at: datetime_utc + updated_at: datetime_utc + + +class CourseDocumentCreate(BaseSchema): + title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] + file_name: Annotated[str, StringConstraints(max_length=MAX_DOC_FILE_NAME)] + course_id: int + author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] + category: COURSE_DOCUMENT_CATEGORIES = "Other" + sub_category: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_SUB_CATEGORY)] | None = None + + +class CourseDocumentUpdate(BaseSchema): + title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] | None = None + file_name: Annotated[str, StringConstraints(max_length=MAX_DOC_FILE_NAME)] | None = None + course_id: int | None = None + author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] | None = None + category: COURSE_DOCUMENT_CATEGORIES | None = None + sub_category: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_SUB_CATEGORY)] | None = None diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py new file mode 100644 index 0000000..47be6ef --- /dev/null +++ b/api_schemas/course_schema.py @@ -0,0 +1,45 @@ +from typing import Annotated +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from api_schemas.course_document_schema import CourseDocumentRead +from api_schemas.program_year_schema import ProgramYearRead +from api_schemas.specialisation_schema import SpecialisationRead +from helpers.constants import MAX_COURSE_CODE, MAX_COURSE_DESC, MAX_COURSE_TITLE +from helpers.types import datetime_utc +from fastapi import UploadFile + + +class SimpleCourseRead(BaseSchema): + course_id: int + title: str + course_code: str | None + + +class CourseRead(BaseSchema): + course_id: int + title: str + course_code: str | None + description: str | None + img_id: int | None + documents: list[CourseDocumentRead] = [] + updated_at: datetime_utc + program_years: list[ProgramYearRead] = [] + specialisations: list[SpecialisationRead] = [] + + +class CourseCreate(BaseSchema): + title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] + course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None + description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None + img_file: UploadFile | None = None + program_year_ids: list[int] = [] + specialisation_ids: list[int] = [] + + +class CourseUpdate(BaseSchema): + title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] | None = None + course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None + description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None + img_file: UploadFile | None = None + program_year_ids: list[int] | None = None + specialisation_ids: list[int] | None = None diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py new file mode 100644 index 0000000..29822d1 --- /dev/null +++ b/api_schemas/program_schema.py @@ -0,0 +1,34 @@ +from typing import Annotated +from fastapi import UploadFile +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE +from api_schemas.program_year_schema import ProgramYearRead +from api_schemas.specialisation_schema import SpecialisationRead + + +class ProgramRead(BaseSchema): + program_id: int + title_sv: str + title_en: str + description_sv: str | None + description_en: str | None + img_id: int | None + program_years: list[ProgramYearRead] = [] + specialisations: list[SpecialisationRead] = [] + + +class ProgramCreate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] + description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None + img_file: UploadFile | None = None + + +class ProgramUpdate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] | None = None + title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] | None = None + description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None + img_file: UploadFile | None = None diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py new file mode 100644 index 0000000..c3c1b5d --- /dev/null +++ b/api_schemas/program_year_schema.py @@ -0,0 +1,35 @@ +from typing import Annotated +from fastapi import UploadFile +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from api_schemas.course_schema import CourseRead +from helpers.constants import MAX_PROGRAM_YEAR_DESC, MAX_PROGRAM_YEAR_TITLE + + +class ProgramYearRead(BaseSchema): + program_year_id: int + title_sv: str + title_en: str + program_id: int + description_sv: str | None + description_en: str | None + img_id: int | None + courses: list[CourseRead] = [] + + +class ProgramYearCreate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] + program_id: int + description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None + img_file: UploadFile | None = None + + +class ProgramYearUpdate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] | None = None + title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] | None = None + program_id: int | None = None + description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None + img_file: UploadFile | None = None diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py new file mode 100644 index 0000000..aae3da9 --- /dev/null +++ b/api_schemas/specialisation_schema.py @@ -0,0 +1,35 @@ +from typing import Annotated +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from api_schemas.course_schema import CourseRead +from helpers.constants import MAX_SPECIALISATION_DESC, MAX_SPECIALISATION_TITLE +from fastapi import UploadFile + + +class SpecialisationRead(BaseSchema): + specialisation_id: int + title_sv: str + title_en: str + program_id: int + description_sv: str | None + description_en: str | None + img_id: int | None + courses: list[CourseRead] = [] + + +class SpecialisationCreate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] + program_id: int + description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None + img_file: UploadFile | None = None + + +class SpecialisationUpdate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] | None = None + title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] | None = None + program_id: int | None = None + description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None + img_file: UploadFile | None = None diff --git a/db_models/course_model.py b/db_models/course_model.py index b5d8b50..e2b263a 100644 --- a/db_models/course_model.py +++ b/db_models/course_model.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import String, ForeignKey from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy +from helpers.types import datetime_utc +from helpers.db_util import latest_modified_column if TYPE_CHECKING: from .associated_img_model import AssociatedImg_DB @@ -21,7 +23,7 @@ class Course_DB(BaseModel_DB): title: Mapped[str] = mapped_column(String(MAX_COURSE_TITLE)) - course_code: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_CODE), default=None) + course_code: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_CODE), default=None, unique=True) description: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_DESC), default=None) @@ -48,3 +50,5 @@ class Course_DB(BaseModel_DB): documents: Mapped[list["CourseDocument_DB"]] = relationship( back_populates="course", cascade="all, delete-orphan", init=False ) + + updated_at: Mapped[datetime_utc] = latest_modified_column() From b5be87fcee8849387ce33c4666bbb37e45eed6e4 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Mon, 23 Mar 2026 16:44:36 +0000 Subject: [PATCH 03/26] Routers added --- .devcontainer/Dockerfile | 2 + api_schemas/associated_img_schema.py | 4 + api_schemas/course_document_schema.py | 11 +- api_schemas/course_schema.py | 8 +- api_schemas/document_schema.py | 2 +- api_schemas/program_schema.py | 7 +- api_schemas/program_year_schema.py | 9 +- api_schemas/specialisation_schema.py | 9 +- helpers/types.py | 1 + routes/__init__.py | 15 +++ routes/course_document_router.py | 149 ++++++++++++++++++++++++++ routes/course_router.py | 73 +++++++++++++ routes/document_router.py | 44 ++------ routes/program_router.py | 64 +++++++++++ routes/program_year_router.py | 76 +++++++++++++ routes/specialisation_router.py | 78 ++++++++++++++ services/course_service.py | 80 ++++++++++++++ services/document_service.py | 47 ++++++++ tests/conftest.py | 1 + 19 files changed, 615 insertions(+), 65 deletions(-) create mode 100644 routes/course_document_router.py create mode 100644 routes/course_router.py create mode 100644 routes/program_router.py create mode 100644 routes/program_year_router.py create mode 100644 routes/specialisation_router.py create mode 100644 services/course_service.py create mode 100644 services/document_service.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 194512a..292c91f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -25,11 +25,13 @@ ENV TEST_REDIS_URL="redis://localhost:6379/0" ENV TEST_DATABASE_URL="postgresql+psycopg://postgres:password@localhost:5432/postgres_test" ENV USER_MANAGER_SECRET="debug_secret_dont_use_in_prod" ENV DOCUMENT_BASE_PATH="/workspaces/WebWebWeb/test-assets/documents" +ENV COURSE_DOCUMENT_BASE_PATH="/workspaces/WebWebWeb/test-assets/course_documents" ENV ALBUM_BASE_PATH="/workspaces/WebWebWeb/test-assets/albums" ENV ASSETS_BASE_PATH="/workspaces/WebWebWeb/test-assets/assets" ENV MOOSE_GAME_TOKEN="sad_secret_key" RUN mkdir -p "$DOCUMENT_BASE_PATH" \ + "$COURSE_DOCUMENT_BASE_PATH" \ "$ALBUM_BASE_PATH" \ "$ASSETS_BASE_PATH" diff --git a/api_schemas/associated_img_schema.py b/api_schemas/associated_img_schema.py index 4b49de5..7337c96 100644 --- a/api_schemas/associated_img_schema.py +++ b/api_schemas/associated_img_schema.py @@ -9,3 +9,7 @@ class AssociatedImgRead(BaseSchema): class AssociatedImgCreate(BaseSchema): file: UploadFile + program_id: int | None = None + program_year_id: int | None = None + course_id: int | None = None + specialisation_id: int | None = None diff --git a/api_schemas/course_document_schema.py b/api_schemas/course_document_schema.py index 3912ebd..9c1b47d 100644 --- a/api_schemas/course_document_schema.py +++ b/api_schemas/course_document_schema.py @@ -1,4 +1,5 @@ from typing import Annotated +from fastapi import UploadFile from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema from helpers.constants import MAX_COURSE_DOC_AUTHOR, MAX_COURSE_DOC_SUB_CATEGORY, MAX_DOC_FILE_NAME, MAX_DOC_TITLE @@ -24,12 +25,12 @@ class CourseDocumentCreate(BaseSchema): author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] category: COURSE_DOCUMENT_CATEGORIES = "Other" sub_category: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_SUB_CATEGORY)] | None = None + file: UploadFile class CourseDocumentUpdate(BaseSchema): - title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] | None = None - file_name: Annotated[str, StringConstraints(max_length=MAX_DOC_FILE_NAME)] | None = None - course_id: int | None = None - author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] | None = None - category: COURSE_DOCUMENT_CATEGORIES | None = None + title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] + file_name: Annotated[str, StringConstraints(max_length=MAX_DOC_FILE_NAME)] + author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] + category: COURSE_DOCUMENT_CATEGORIES = "Other" sub_category: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_SUB_CATEGORY)] | None = None diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py index 47be6ef..df6e7cc 100644 --- a/api_schemas/course_schema.py +++ b/api_schemas/course_schema.py @@ -31,15 +31,13 @@ class CourseCreate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None - img_file: UploadFile | None = None program_year_ids: list[int] = [] specialisation_ids: list[int] = [] class CourseUpdate(BaseSchema): - title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] | None = None + title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None - img_file: UploadFile | None = None - program_year_ids: list[int] | None = None - specialisation_ids: list[int] | None = None + program_year_ids: list[int] = [] + specialisation_ids: list[int] = [] diff --git a/api_schemas/document_schema.py b/api_schemas/document_schema.py index b0d5c71..bbc7f4f 100644 --- a/api_schemas/document_schema.py +++ b/api_schemas/document_schema.py @@ -1,5 +1,5 @@ from typing import Annotated -from fastapi import Form, UploadFile +from fastapi import Form from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema from api_schemas.user_schemas import SimpleUserRead diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index 29822d1..b028b67 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -1,5 +1,4 @@ from typing import Annotated -from fastapi import UploadFile from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE @@ -23,12 +22,10 @@ class ProgramCreate(BaseSchema): title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None - img_file: UploadFile | None = None class ProgramUpdate(BaseSchema): - title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] | None = None - title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] | None = None + title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None - img_file: UploadFile | None = None diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index c3c1b5d..e3f5e7a 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -1,5 +1,4 @@ from typing import Annotated -from fastapi import UploadFile from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema from api_schemas.course_schema import CourseRead @@ -23,13 +22,11 @@ class ProgramYearCreate(BaseSchema): program_id: int description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None - img_file: UploadFile | None = None class ProgramYearUpdate(BaseSchema): - title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] | None = None - title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] | None = None - program_id: int | None = None + title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] + program_id: int description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None - img_file: UploadFile | None = None diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index aae3da9..39fb87a 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -3,7 +3,6 @@ from api_schemas.base_schema import BaseSchema from api_schemas.course_schema import CourseRead from helpers.constants import MAX_SPECIALISATION_DESC, MAX_SPECIALISATION_TITLE -from fastapi import UploadFile class SpecialisationRead(BaseSchema): @@ -23,13 +22,11 @@ class SpecialisationCreate(BaseSchema): program_id: int description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None - img_file: UploadFile | None = None class SpecialisationUpdate(BaseSchema): - title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] | None = None - title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] | None = None - program_id: int | None = None + title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] + program_id: int description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None - img_file: UploadFile | None = None diff --git a/helpers/types.py b/helpers/types.py index 6d90863..1716db8 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -59,6 +59,7 @@ def force_utc(date: datetime): "Moosegame", "MailAlias", "GuildMeeting", + "Plugg", ] # This is a little ridiculous now, but if we have many actions, this is a neat system. diff --git a/routes/__init__.py b/routes/__init__.py index 079d5b4..71919a7 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -31,6 +31,11 @@ from .sub_election_router import sub_election_router from .nomination_router import nomination_router from .guild_meeting_router import guild_meeting_router +from .program_router import program_router +from .program_year_router import program_year_router +from .specialisation_router import specialisation_router +from .course_router import course_router +from .course_document_router import course_document_router # here comes the big momma router main_router = APIRouter() @@ -94,3 +99,13 @@ main_router.include_router(nomination_router, prefix="/nominations", tags=["nominations"]) main_router.include_router(guild_meeting_router, prefix="/guild-meeting", tags=["guild meeting"]) + +main_router.include_router(program_router, prefix="/programs", tags=["programs"]) + +main_router.include_router(program_year_router, prefix="/program-years", tags=["program years"]) + +main_router.include_router(specialisation_router, prefix="/specialisations", tags=["specialisations"]) + +main_router.include_router(course_router, prefix="/courses", tags=["courses"]) + +main_router.include_router(course_document_router, prefix="/course-documents", tags=["course documents"]) diff --git a/routes/course_document_router.py b/routes/course_document_router.py new file mode 100644 index 0000000..8527986 --- /dev/null +++ b/routes/course_document_router.py @@ -0,0 +1,149 @@ +import os +from pathlib import Path + +from fastapi import APIRouter, HTTPException, status, Response +from fastapi.responses import FileResponse +from sqlalchemy.exc import IntegrityError + +from api_schemas.course_document_schema import CourseDocumentCreate, CourseDocumentRead, CourseDocumentUpdate +from database import DB_dependency +from db_models.course_document_model import CourseDocument_DB +from db_models.course_model import Course_DB +from services.document_service import validate_file +from user.permission import Permission + + +course_document_router = APIRouter() + + +@course_document_router.get("/", response_model=list[CourseDocumentRead]) +def get_all_course_documents(db: DB_dependency): + return db.query(CourseDocument_DB).all() + + +@course_document_router.get("/{course_document_id}", response_model=CourseDocumentRead) +def get_course_document(course_document_id: int, db: DB_dependency): + course_document = db.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() + if course_document is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return course_document + + +@course_document_router.post( + "/", response_model=CourseDocumentRead, dependencies=[Permission.require("manage", "Plugg")] +) +async def create_course_document(data: CourseDocumentCreate, db: DB_dependency): + course = db.query(Course_DB).filter_by(course_id=data.course_id).one_or_none() + if course is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Course not found") + + base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Document base path is not configured") + + sanitized_filename, ext, file_path = await validate_file(base_path, data.file) + + course_document = CourseDocument_DB( + title=data.title, + file_name=f"{sanitized_filename}{ext}", + course_id=data.course_id, + author=data.author, + category=data.category, + sub_category=data.sub_category, + ) + + try: + db.add(course_document) + db.commit() + file_path.write_bytes(data.file.file.read()) + db.refresh(course_document) + except IntegrityError: + db.rollback() + raise HTTPException(400, detail="Something is invalid") + + return course_document + + +@course_document_router.patch( + "/{course_document_id}", + response_model=CourseDocumentRead, + dependencies=[Permission.require("manage", "Plugg")], +) +def update_course_document(course_document_id: int, data: CourseDocumentUpdate, db: DB_dependency): + course_document = db.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() + if course_document is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + for var, value in vars(data).items(): + # Note that we always set None values, to clear fields if the user wants to. + setattr(course_document, var, value) + + db.commit() + db.refresh(course_document) + return course_document + + +@course_document_router.get("/document_file/{course_document_id}") +def get_course_document_file_by_id(course_document_id: int, db: DB_dependency): + base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + + document = ( + db.query(CourseDocument_DB).filter(CourseDocument_DB.course_document_id == course_document_id).one_or_none() + ) + if document is None: + raise HTTPException(404, detail="Document not found") + + file_path = Path(f"{base_path}/{document.file_name}") + if not file_path.exists(): + raise HTTPException(418, detail="Something is very cooked, contact the Webmasters pls!") + + return FileResponse(file_path, filename=document.file_name, media_type="application/octet-stream") + + +@course_document_router.get("/{course_document_id}") +def get_course_document_file( + course_document_id: int, + db: DB_dependency, + response: Response, +): + base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + + document = ( + db.query(CourseDocument_DB).filter(CourseDocument_DB.course_document_id == course_document_id).one_or_none() + ) + if document is None: + raise HTTPException(404, detail="Document not found") + + file_path = Path(f"/internal/document{base_path}/{document.file_name}") + if not file_path.exists(): + # This will always trigger if we are in local dev, don't worry about it + # If we get this in production something is very wrong + raise HTTPException(418, detail="Something is very cooked, contact the Webmasters pls!") + + response.headers["X-Accel-Redirect"] = str(file_path) + + # I got an error in swagger if these were not included + response.status_code = 200 + response.body = b"" + return response + + +@course_document_router.delete( + "/{course_document_id}", + response_model=CourseDocumentRead, + dependencies=[Permission.require("manage", "Plugg")], +) +def delete_course_document(course_document_id: int, db: DB_dependency): + base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + course_document = db.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() + if course_document is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + try: + db.delete(course_document) + db.commit() + os.remove(f"{base_path}/{course_document.file_name}") + except IntegrityError: + db.rollback() + raise HTTPException(500, detail="Something went wrong trying to delete the document, contact the Webmasters") + return course_document diff --git a/routes/course_router.py b/routes/course_router.py new file mode 100644 index 0000000..20ee2db --- /dev/null +++ b/routes/course_router.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, HTTPException, status + +from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate +from database import DB_dependency +from db_models.course_model import Course_DB +from services.course_service import update_course_relationships +from user.permission import Permission + + +course_router = APIRouter() + + +@course_router.get("/", response_model=list[CourseRead]) +def get_all_courses(db: DB_dependency): + return db.query(Course_DB).all() + + +@course_router.get("/{course_id}", response_model=CourseRead) +def get_course(course_id: int, db: DB_dependency): + course = db.query(Course_DB).filter_by(course_id=course_id).one_or_none() + if course is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return course + + +@course_router.post("/", response_model=CourseRead, dependencies=[Permission.require("manage", "Plugg")]) +def create_course(data: CourseCreate, db: DB_dependency): + course = Course_DB( + title=data.title, + course_code=data.course_code, + description=data.description, + ) + db.add(course) + db.commit() + db.refresh(course) + + # We handle program_year_ids and specialisation_ids separately, since they are many-to-many relationships. + course = update_course_relationships(course, data, db) + + db.commit() + db.refresh(course) + + return course + + +@course_router.patch("/{course_id}", response_model=CourseRead, dependencies=[Permission.require("manage", "Plugg")]) +def update_course(course_id: int, data: CourseUpdate, db: DB_dependency): + course = db.query(Course_DB).filter_by(course_id=course_id).one_or_none() + if course is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + for var, value in vars(data).items(): + if var != "program_year_ids" and var != "specialisation_ids": + # Note that we always set None values, to clear fields if the user wants to. + setattr(course, var, value) + + # We handle program_year_ids and specialisation_ids separately, since they are many-to-many relationships. + course = update_course_relationships(course, data, db) + + db.commit() + db.refresh(course) + return course + + +@course_router.delete("/{course_id}", response_model=CourseRead, dependencies=[Permission.require("manage", "Plugg")]) +def delete_course(course_id: int, db: DB_dependency): + course = db.query(Course_DB).filter_by(course_id=course_id).one_or_none() + if course is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + db.delete(course) + db.commit() + return course diff --git a/routes/document_router.py b/routes/document_router.py index a36f89b..fb32104 100644 --- a/routes/document_router.py +++ b/routes/document_router.py @@ -5,14 +5,12 @@ from db_models.document_model import Document_DB from api_schemas.document_schema import DocumentRead, DocumentCreate, DocumentUpdate, document_create_form from db_models.user_model import User_DB -from helpers.constants import MAX_DOC_TITLE, MAX_FILE_SIZE_MB -from helpers.pdf_checker import validate_pdf_header from user.permission import Permission from fastapi import File, UploadFile, HTTPException import os from fastapi.responses import FileResponse -from helpers.db_util import sanitize_title from sqlalchemy.exc import IntegrityError +from services.document_service import validate_file from pathlib import Path @@ -36,39 +34,10 @@ async def upload_document( file: UploadFile = File(), ): base_path = os.getenv("DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Document base path is not configured") - await validate_pdf_header(file) - - if file.filename is None: - raise HTTPException(400, detail="The file has no name") - - filename, ext = os.path.splitext(str(file.filename)) - - sanitized_filename = sanitize_title(filename) - - if len(sanitized_filename) > MAX_DOC_TITLE: - raise HTTPException(400, detail="The file name is too long") - - allowed_exts = {".pdf"} - - ext = ext.lower() - - if ext not in allowed_exts: - raise HTTPException(400, "File extension not allowed") - - if file.size is None: - raise HTTPException(400, detail="Could not determine file size") - - if file.size > MAX_FILE_SIZE_MB * 1024 * 1024: - raise HTTPException( - 400, detail=f"File size is too large! Compress the file to smaller than {MAX_FILE_SIZE_MB}MB" - ) - - file.filename = f"{sanitized_filename}{ext}" - - file_path = Path(f"{base_path}/{sanitized_filename}{ext}") - if file_path.is_file(): - raise HTTPException(409, detail="Filename is equal to already existing file") + sanitized_filename, ext, file_path = await validate_file(base_path, file) document = Document_DB( title=data.title, @@ -168,11 +137,12 @@ def delete_document(document_id: int, db: DB_dependency): try: db.delete(document) db.commit() + # Only delete the file if the database deletion was successful + os.remove(f"{base_path}/{document.file_name}") except IntegrityError: + db.rollback() raise HTTPException(500, detail="Something went wrong trying to delete the document, contact the Webmasters") - os.remove(f"{base_path}/{document.file_name}") - return document diff --git a/routes/program_router.py b/routes/program_router.py new file mode 100644 index 0000000..960f2c1 --- /dev/null +++ b/routes/program_router.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, HTTPException, status + +from api_schemas.program_schema import ProgramCreate, ProgramRead, ProgramUpdate +from database import DB_dependency +from db_models.program_model import Program_DB +from user.permission import Permission + + +program_router = APIRouter() + + +@program_router.get("/", response_model=list[ProgramRead]) +def get_all_programs(db: DB_dependency): + return db.query(Program_DB).all() + + +@program_router.get("/{program_id}", response_model=ProgramRead) +def get_program(program_id: int, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return program + + +@program_router.post("/", response_model=ProgramRead, dependencies=[Permission.require("manage", "Plugg")]) +def create_program(data: ProgramCreate, db: DB_dependency): + program = Program_DB( + title_sv=data.title_sv, + title_en=data.title_en, + description_sv=data.description_sv, + description_en=data.description_en, + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +@program_router.patch("/{program_id}", response_model=ProgramRead, dependencies=[Permission.require("manage", "Plugg")]) +def update_program(program_id: int, data: ProgramUpdate, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + for var, value in vars(data).items(): + # Note that we always set None values, to clear fields if the user wants to. + setattr(program, var, value) + + db.commit() + db.refresh(program) + return program + + +@program_router.delete( + "/{program_id}", response_model=ProgramRead, dependencies=[Permission.require("manage", "Plugg")] +) +def delete_program(program_id: int, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + db.delete(program) + db.commit() + return program diff --git a/routes/program_year_router.py b/routes/program_year_router.py new file mode 100644 index 0000000..c5d0587 --- /dev/null +++ b/routes/program_year_router.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, HTTPException, status + +from api_schemas.program_year_schema import ProgramYearCreate, ProgramYearRead, ProgramYearUpdate +from database import DB_dependency +from db_models.program_model import Program_DB +from db_models.program_year_model import ProgramYear_DB +from user.permission import Permission + + +program_year_router = APIRouter() + + +@program_year_router.get("/", response_model=list[ProgramYearRead]) +def get_all_program_years(db: DB_dependency): + return db.query(ProgramYear_DB).all() + + +@program_year_router.get("/{program_year_id}", response_model=ProgramYearRead) +def get_program_year(program_year_id: int, db: DB_dependency): + program_year = db.query(ProgramYear_DB).filter_by(program_year_id=program_year_id).one_or_none() + if program_year is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return program_year + + +@program_year_router.post("/", response_model=ProgramYearRead, dependencies=[Permission.require("manage", "Plugg")]) +def create_program_year(data: ProgramYearCreate, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + + program_year = ProgramYear_DB( + title_sv=data.title_sv, + title_en=data.title_en, + program_id=data.program_id, + description_sv=data.description_sv, + description_en=data.description_en, + ) + db.add(program_year) + db.commit() + db.refresh(program_year) + return program_year + + +@program_year_router.patch( + "/{program_year_id}", response_model=ProgramYearRead, dependencies=[Permission.require("manage", "Plugg")] +) +def update_program_year(program_year_id: int, data: ProgramYearUpdate, db: DB_dependency): + program_year = db.query(ProgramYear_DB).filter_by(program_year_id=program_year_id).one_or_none() + if program_year is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + + for var, value in vars(data).items(): + # Note that we always set None values, to clear fields if the user wants to. + setattr(program_year, var, value) + + db.commit() + db.refresh(program_year) + return program_year + + +@program_year_router.delete( + "/{program_year_id}", response_model=ProgramYearRead, dependencies=[Permission.require("manage", "Plugg")] +) +def delete_program_year(program_year_id: int, db: DB_dependency): + program_year = db.query(ProgramYear_DB).filter_by(program_year_id=program_year_id).one_or_none() + if program_year is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + db.delete(program_year) + db.commit() + return program_year diff --git a/routes/specialisation_router.py b/routes/specialisation_router.py new file mode 100644 index 0000000..3e3c878 --- /dev/null +++ b/routes/specialisation_router.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, HTTPException, status + +from api_schemas.specialisation_schema import SpecialisationCreate, SpecialisationRead, SpecialisationUpdate +from database import DB_dependency +from db_models.program_model import Program_DB +from db_models.specialisation_model import Specialisation_DB +from user.permission import Permission + + +specialisation_router = APIRouter() + + +@specialisation_router.get("/", response_model=list[SpecialisationRead]) +def get_all_specialisations(db: DB_dependency): + return db.query(Specialisation_DB).all() + + +@specialisation_router.get("/{specialisation_id}", response_model=SpecialisationRead) +def get_specialisation(specialisation_id: int, db: DB_dependency): + specialisation = db.query(Specialisation_DB).filter_by(specialisation_id=specialisation_id).one_or_none() + if specialisation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return specialisation + + +@specialisation_router.post( + "/", response_model=SpecialisationRead, dependencies=[Permission.require("manage", "Plugg")] +) +def create_specialisation(data: SpecialisationCreate, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + + specialisation = Specialisation_DB( + title_sv=data.title_sv, + title_en=data.title_en, + program_id=data.program_id, + description_sv=data.description_sv, + description_en=data.description_en, + ) + db.add(specialisation) + db.commit() + db.refresh(specialisation) + return specialisation + + +@specialisation_router.patch( + "/{specialisation_id}", response_model=SpecialisationRead, dependencies=[Permission.require("manage", "Plugg")] +) +def update_specialisation(specialisation_id: int, data: SpecialisationUpdate, db: DB_dependency): + specialisation = db.query(Specialisation_DB).filter_by(specialisation_id=specialisation_id).one_or_none() + if specialisation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + + for var, value in vars(data).items(): + # Note that we always set None values, to clear fields if the user wants to. + setattr(specialisation, var, value) + + db.commit() + db.refresh(specialisation) + return specialisation + + +@specialisation_router.delete( + "/{specialisation_id}", response_model=SpecialisationRead, dependencies=[Permission.require("manage", "Plugg")] +) +def delete_specialisation(specialisation_id: int, db: DB_dependency): + specialisation = db.query(Specialisation_DB).filter_by(specialisation_id=specialisation_id).one_or_none() + if specialisation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + db.delete(specialisation) + db.commit() + return specialisation diff --git a/services/course_service.py b/services/course_service.py new file mode 100644 index 0000000..b2e7d75 --- /dev/null +++ b/services/course_service.py @@ -0,0 +1,80 @@ +from sqlalchemy.exc import DataError + +from api_schemas.course_schema import CourseCreate, CourseUpdate +from course_model import Course_DB +from database import DB_dependency +from db_models.program_year_model import ProgramYear_DB +from db_models.specialisation_model import Specialisation_DB +from fastapi import HTTPException, status + + +def update_course_relationships(course: Course_DB, update_course: CourseUpdate | CourseCreate, db: DB_dependency): + # post_ids = update_posts.post_ids + program_year_ids = update_course.program_year_ids + specialisation_ids = update_course.specialisation_ids + + # Fetch all program years with the given IDs + program_years = db.query(ProgramYear_DB).filter(ProgramYear_DB.program_year_id.in_(program_year_ids)).all() + program_years_by_id = {program_year.program_year_id: program_year for program_year in program_years} + + # Fetch all specialisations with the given IDs + specialisations = ( + db.query(Specialisation_DB).filter(Specialisation_DB.specialisation_id.in_(specialisation_ids)).all() + ) + specialisations_by_id = {specialisation.specialisation_id: specialisation for specialisation in specialisations} + + # Check if all program years exist in the database + missing_program_year_ids = [ + program_year_id for program_year_id in program_year_ids if program_year_id not in program_years_by_id + ] + if missing_program_year_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"Program years with ids {missing_program_year_ids} not found" + ) + + # Check if all specialisations exist in the database + missing_specialisation_ids = [ + specialisation_id for specialisation_id in specialisation_ids if specialisation_id not in specialisations_by_id + ] + if missing_specialisation_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"Specialisations with ids {missing_specialisation_ids} not found" + ) + + # Add new program years to the course + for program_year_id in program_year_ids: + program_year = program_years_by_id[program_year_id] + if program_year not in course.program_years: + course.program_years.append(program_year) + + # Add new specialisations to the course + for specialisation_id in specialisation_ids: + specialisation = specialisations_by_id[specialisation_id] + if specialisation not in course.specialisations: + course.specialisations.append(specialisation) + + # Remove program years not in the new list + program_years_to_remove = [ + program_year for program_year in course.program_years if program_year.program_year_id not in program_year_ids + ] + for program_year in program_years_to_remove: + course.program_years.remove(program_year) + + # Remove specialisations not in the new list + specialisations_to_remove = [ + specialisation + for specialisation in course.specialisations + if specialisation.specialisation_id not in specialisation_ids + ] + for specialisation in specialisations_to_remove: + course.specialisations.remove(specialisation) + + try: + db.commit() + except DataError: + db.rollback() + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail="Error updating course relationships (program years or specialisations)" + ) + + return course diff --git a/services/document_service.py b/services/document_service.py new file mode 100644 index 0000000..a2dae7a --- /dev/null +++ b/services/document_service.py @@ -0,0 +1,47 @@ +# Used both in document_router and in course_document_router. + +from fastapi import HTTPException +from helpers.constants import MAX_DOC_TITLE, MAX_FILE_SIZE_MB +from helpers.pdf_checker import validate_pdf_header +from fastapi import File, UploadFile, HTTPException +import os +from helpers.db_util import sanitize_title +from pathlib import Path + + +async def validate_file(base_path: str, file: UploadFile = File()): + + await validate_pdf_header(file) + + if file.filename is None: + raise HTTPException(400, detail="The file has no name") + + filename, ext = os.path.splitext(str(file.filename)) + + sanitized_filename = sanitize_title(filename) + + if len(sanitized_filename) > MAX_DOC_TITLE: + raise HTTPException(400, detail="The file name is too long") + + allowed_exts = {".pdf"} + + ext = ext.lower() + + if ext not in allowed_exts: + raise HTTPException(400, "File extension not allowed") + + if file.size is None: + raise HTTPException(400, detail="Could not determine file size") + + if file.size > MAX_FILE_SIZE_MB * 1024 * 1024: + raise HTTPException( + 400, detail=f"File size is too large! Compress the file to smaller than {MAX_FILE_SIZE_MB}MB" + ) + + file.filename = f"{sanitized_filename}{ext}" + + file_path = Path(f"{base_path}/{sanitized_filename}{ext}") + if file_path.is_file(): + raise HTTPException(409, detail="Filename is equal to already existing file") + + return sanitized_filename, ext, file_path diff --git a/tests/conftest.py b/tests/conftest.py index 516e7c4..e37b8fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ # Set file storage path for pytest os.environ["DOCUMENT_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/documents" +os.environ["COURSE_DOCUMENT_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/course_documents" os.environ["ALBUM_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/albums" os.environ["ASSETS_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/assets" From 3ee952f780762c474ab395791c56ad3b97684985 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sun, 12 Apr 2026 10:38:13 +0000 Subject: [PATCH 04/26] Fix circular schema import errors preventing compilation --- api_schemas/program_schema.py | 6 ++++++ api_schemas/program_year_schema.py | 8 +++++--- api_schemas/specialisation_schema.py | 8 +++++--- services/course_service.py | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index b028b67..8c9606e 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -4,6 +4,12 @@ from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE from api_schemas.program_year_schema import ProgramYearRead from api_schemas.specialisation_schema import SpecialisationRead +from api_schemas.course_schema import ( + CourseRead, +) # pyright: ignore[reportUnusedImport] # This is not used but is needed for the model_rebuild calls below + +ProgramYearRead.model_rebuild() +SpecialisationRead.model_rebuild() class ProgramRead(BaseSchema): diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index e3f5e7a..98ebb82 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -1,9 +1,11 @@ -from typing import Annotated +from typing import Annotated, TYPE_CHECKING from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema -from api_schemas.course_schema import CourseRead from helpers.constants import MAX_PROGRAM_YEAR_DESC, MAX_PROGRAM_YEAR_TITLE +if TYPE_CHECKING: + from api_schemas.course_schema import CourseRead + class ProgramYearRead(BaseSchema): program_year_id: int @@ -13,7 +15,7 @@ class ProgramYearRead(BaseSchema): description_sv: str | None description_en: str | None img_id: int | None - courses: list[CourseRead] = [] + courses: list["CourseRead"] = [] class ProgramYearCreate(BaseSchema): diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index 39fb87a..cec5e9e 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -1,9 +1,11 @@ -from typing import Annotated +from typing import Annotated, TYPE_CHECKING from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema -from api_schemas.course_schema import CourseRead from helpers.constants import MAX_SPECIALISATION_DESC, MAX_SPECIALISATION_TITLE +if TYPE_CHECKING: + from api_schemas.course_schema import CourseRead + class SpecialisationRead(BaseSchema): specialisation_id: int @@ -13,7 +15,7 @@ class SpecialisationRead(BaseSchema): description_sv: str | None description_en: str | None img_id: int | None - courses: list[CourseRead] = [] + courses: list["CourseRead"] = [] class SpecialisationCreate(BaseSchema): diff --git a/services/course_service.py b/services/course_service.py index b2e7d75..395f1df 100644 --- a/services/course_service.py +++ b/services/course_service.py @@ -1,7 +1,7 @@ from sqlalchemy.exc import DataError from api_schemas.course_schema import CourseCreate, CourseUpdate -from course_model import Course_DB +from db_models.course_model import Course_DB from database import DB_dependency from db_models.program_year_model import ProgramYear_DB from db_models.specialisation_model import Specialisation_DB From ca14db7a0d0481012eaf1eee6b2253edac1a5ccd Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sun, 12 Apr 2026 13:50:26 +0000 Subject: [PATCH 05/26] Add tests --- tests/basic_factories.py | 98 +++++++++ tests/basic_fixtures.py | 2 + tests/test_course_documents.py | 234 ++++++++++++++++++++ tests/test_plugg_resources.py | 389 +++++++++++++++++++++++++++++++++ 4 files changed, 723 insertions(+) create mode 100644 tests/test_course_documents.py create mode 100644 tests/test_plugg_resources.py diff --git a/tests/basic_factories.py b/tests/basic_factories.py index 39a1bed..dfab31e 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -128,3 +128,101 @@ def patch_sub_election(client, sub_election_id, token=None, **kwargs): data = sub_election_data_factory(**kwargs) headers = auth_headers(token) if token else {} return client.patch(f"/sub-election/{sub_election_id}", json=data, headers=headers) + + +def program_data_factory(**kwargs): + """Factory for creating program payloads.""" + default_data = { + "title_sv": "Tekniskt basår", + "title_en": "Engineering basics", + "description_sv": "Svensk programbeskrivning", + "description_en": "English program description", + } + return {**default_data, **kwargs} + + +def create_program(client, token=None, **kwargs): + """Helper to POST /programs/ with optional token and payload overrides.""" + data = program_data_factory(**kwargs) + headers = auth_headers(token) if token else {} + return client.post("/programs/", json=data, headers=headers) + + +def program_year_data_factory(program_id=1, **kwargs): + """Factory for creating program year payloads.""" + default_data = { + "title_sv": "Årskurs 1", + "title_en": "Year 1", + "program_id": program_id, + "description_sv": "Svensk årskursbeskrivning", + "description_en": "English program year description", + } + return {**default_data, **kwargs} + + +def create_program_year(client, token=None, **kwargs): + """Helper to POST /program-years/ with optional token and payload overrides.""" + data = program_year_data_factory(**kwargs) + headers = auth_headers(token) if token else {} + return client.post("/program-years/", json=data, headers=headers) + + +def specialisation_data_factory(program_id=1, **kwargs): + """Factory for creating specialisation payloads.""" + default_data = { + "title_sv": "Maskininlarning", + "title_en": "Machine learning", + "program_id": program_id, + "description_sv": "Svensk specialiseringsbeskrivning", + "description_en": "English specialisation description", + } + return {**default_data, **kwargs} + + +def create_specialisation(client, token=None, **kwargs): + """Helper to POST /specialisations/ with optional token and payload overrides.""" + data = specialisation_data_factory(**kwargs) + headers = auth_headers(token) if token else {} + return client.post("/specialisations/", json=data, headers=headers) + + +def course_data_factory(program_year_ids=None, specialisation_ids=None, **kwargs): + """Factory for creating course payloads.""" + default_data = { + "title": "Lineär algebra", + "course_code": "FMAA01", + "description": "Hoppas du gillar matriser", + "program_year_ids": program_year_ids if program_year_ids is not None else [], + "specialisation_ids": specialisation_ids if specialisation_ids is not None else [], + } + return {**default_data, **kwargs} + + +def create_course(client, token=None, **kwargs): + """Helper to POST /courses/ with optional token and payload overrides.""" + data = course_data_factory(**kwargs) + headers = auth_headers(token) if token else {} + return client.post("/courses/", json=data, headers=headers) + + +def course_document_data_factory(course_id=1, **kwargs): + """Factory for creating course document multipart form fields.""" + default_data = { + "title": "Lecture notes week 1", + "file_name": "lecture_notes_w1.pdf", + "course_id": course_id, + "author": "Mr. Test", + "category": "Notes", + "sub_category": "By Mr. Test", + } + data = {**default_data, **kwargs} + data["course_id"] = str(data["course_id"]) + return data + + +def create_course_document(client, token=None, file=None, **kwargs): + """Helper to POST /course-documents/ with multipart data and optional auth.""" + data = course_document_data_factory(**kwargs) + headers = auth_headers(token) if token else {} + files = {"file": file} if file is not None else None + return client.post("/course-documents/", data=data, files=files, headers=headers) diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index 5ff38de..1149c7b 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -98,6 +98,8 @@ def admin_post(db_session): Permission_DB(action="manage", target="Document"), Permission_DB(action="view", target="GuildMeeting"), Permission_DB(action="manage", target="GuildMeeting"), + Permission_DB(action="manage", target="Plugg"), + Permission_DB(action="view", target="Plugg"), ] post.permissions.extend(permissions) db_session.commit() diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py new file mode 100644 index 0000000..1c9085a --- /dev/null +++ b/tests/test_course_documents.py @@ -0,0 +1,234 @@ +# type: ignore +import os +import pytest + +from .basic_factories import ( + auth_headers, + course_document_data_factory, + create_course_document, +) + + +@pytest.fixture +def plugg_course_id(db_session): + from db_models.course_model import Course_DB + + course = Course_DB( + title="Grundkurs i programmering", + course_code="EDAA01", + description="Test course for course-document tests", + ) + db_session.add(course) + db_session.commit() + db_session.refresh(course) + return course.course_id + + +def test_create_course_document_success(client, admin_token, plugg_course_id, example_file): + response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + + assert response.status_code in (200, 201), response.text + data = response.json() + assert data["title"] == "Lecture notes" + assert data["author"] == "Tester" + assert data["category"] == "Notes" + assert data["course_id"] == plugg_course_id + assert data["file_name"].endswith(".pdf") + + +def test_patch_course_document_success(client, admin_token, plugg_course_id, example_file): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + patch_data = { + "title": "Updated notes", + "file_name": "updated_notes.pdf", + "author": "Updated author", + "category": "Summary", + "sub_category": "by author 2", + } + + patch_response = client.patch( + f"/course-documents/{document_id}", + json=patch_data, + headers=auth_headers(admin_token), + ) + + assert patch_response.status_code == 200, patch_response.text + data = patch_response.json() + assert data["title"] == "Updated notes" + assert data["file_name"] == "updated_notes.pdf" + assert data["author"] == "Updated author" + assert data["category"] == "Summary" + + +def test_get_course_document_by_id_public(client, admin_token, plugg_course_id, example_file): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + response = client.get(f"/course-documents/{document_id}") + assert response.status_code == 200 + assert response.json()["course_document_id"] == document_id + + +def test_get_all_course_documents_public(client, admin_token, plugg_course_id, example_file): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + response = client.get("/course-documents/") + assert response.status_code == 200 + assert any(document["course_document_id"] == document_id for document in response.json()) + + +def test_create_course_document_requires_existing_course(client, admin_token, example_file): + response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=999999), + ) + + assert response.status_code == 400 + + +def test_unauthorized_cannot_upload_course_document(client, plugg_course_id, example_file): + response = create_course_document( + client, + token=None, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + + assert response.status_code in (401, 403) + + +def test_member_cannot_upload_course_document( + client, + member_token, + plugg_course_id, + example_file, +): + response = create_course_document( + client, + token=member_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + + assert response.status_code == 403 + + +def test_unauthorized_cannot_patch_course_document( + client, + admin_token, + plugg_course_id, + example_file, +): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + patch_data = { + "title": "Forbidden patch", + "file_name": "forbidden.pdf", + "author": "Unauthorized", + "category": "Other", + "sub_category": None, + } + response = client.patch(f"/course-documents/{document_id}", json=patch_data) + + assert response.status_code in (401, 403) + + +def test_member_cannot_patch_course_document(client, member_token, admin_token, plugg_course_id, example_file): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + patch_data = { + "title": "Forbidden patch", + "file_name": "forbidden.pdf", + "author": "Member", + "category": "Other", + "sub_category": None, + } + response = client.patch( + f"/course-documents/{document_id}", + json=patch_data, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + + +def test_delete_course_document_success(client, admin_token, plugg_course_id, example_file): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + get_response = client.get(f"/course-documents/{document_id}") + assert get_response.status_code == 200 + + delete_response = client.delete(f"/course-documents/{document_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 200 + + get_response = client.get(f"/course-documents/{document_id}") + assert get_response.status_code == 404 + + +def test_member_cannot_delete_course_document( + client, + member_token, + admin_token, + plugg_course_id, + example_file, +): + create_response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert create_response.status_code in (200, 201), create_response.text + document_id = create_response.json()["course_document_id"] + + response = client.delete(f"/course-documents/{document_id}", headers=auth_headers(member_token)) + assert response.status_code == 403 diff --git a/tests/test_plugg_resources.py b/tests/test_plugg_resources.py new file mode 100644 index 0000000..07e6084 --- /dev/null +++ b/tests/test_plugg_resources.py @@ -0,0 +1,389 @@ +# type: ignore +import pytest + +from .basic_factories import ( + auth_headers, + course_data_factory, + create_course, + create_program, + create_program_year, + create_specialisation, + program_data_factory, + program_year_data_factory, + specialisation_data_factory, +) + + +@pytest.fixture +def base_program(client, admin_token): + response = create_program(client, token=admin_token) + assert response.status_code in (200, 201), response.text + return response.json() + + +@pytest.fixture +def plugg_relationship_ids(client, admin_token, base_program): + program_id = base_program["program_id"] + + year_resp = create_program_year(client, token=admin_token, program_id=program_id) + assert year_resp.status_code in (200, 201), year_resp.text + + specialisation_resp = create_specialisation(client, token=admin_token, program_id=program_id) + assert specialisation_resp.status_code in (200, 201), specialisation_resp.text + + return { + "program_id": program_id, + "program_year_id": year_resp.json()["program_year_id"], + "specialisation_id": specialisation_resp.json()["specialisation_id"], + } + + +def test_create_program_success(client, admin_token): + response = create_program(client, token=admin_token) + + assert response.status_code in (200, 201) + data = response.json() + expected = program_data_factory() + assert data["title_sv"] == expected["title_sv"] + assert data["title_en"] == expected["title_en"] + assert "program_id" in data + + +@pytest.mark.parametrize("token_fixture, expected_status", [("member_token", 403), (None, 401)]) +def test_create_program_requires_permission(client, request, token_fixture, expected_status): + token = request.getfixturevalue(token_fixture) if token_fixture else None + + response = create_program(client, token=token) + assert response.status_code == expected_status + + +def test_program_read_endpoints_are_public(client, admin_token): + create_response = create_program(client, token=admin_token) + assert create_response.status_code in (200, 201) + program_id = create_response.json()["program_id"] + + list_response = client.get("/programs/") + assert list_response.status_code == 200 + assert any(program["program_id"] == program_id for program in list_response.json()) + + detail_response = client.get(f"/programs/{program_id}") + assert detail_response.status_code == 200 + assert detail_response.json()["program_id"] == program_id + + +def test_update_program_success(client, admin_token): + create_response = create_program(client, token=admin_token) + assert create_response.status_code in (200, 201) + program_id = create_response.json()["program_id"] + + update_data = program_data_factory(title_sv="Uppdaterat program", title_en="Updated program") + update_response = client.patch( + f"/programs/{program_id}", + json=update_data, + headers=auth_headers(admin_token), + ) + + assert update_response.status_code == 200 + data = update_response.json() + assert data["title_sv"] == "Uppdaterat program" + assert data["title_en"] == "Updated program" + + +def test_delete_program_success(client, admin_token): + create_response = create_program(client, token=admin_token) + assert create_response.status_code in (200, 201) + program_id = create_response.json()["program_id"] + + get_response = client.get(f"/programs/{program_id}") + assert get_response.status_code in (200, 201) + + delete_response = client.delete(f"/programs/{program_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 200 + + get_response = client.get(f"/programs/{program_id}") + assert get_response.status_code == 404 + + +def test_create_program_year_success(client, admin_token, base_program): + program_id = base_program["program_id"] + + response = create_program_year(client, token=admin_token, program_id=program_id) + assert response.status_code in (200, 201) + + data = response.json() + expected = program_year_data_factory(program_id=program_id) + assert data["program_id"] == program_id + assert data["title_sv"] == expected["title_sv"] + assert "program_year_id" in data + + +def test_create_program_year_requires_existing_program(client, admin_token): + response = create_program_year(client, token=admin_token, program_id=999999) + assert response.status_code == 400 + + +def test_update_program_year_success(client, admin_token, base_program): + program_id = base_program["program_id"] + create_year_response = create_program_year(client, token=admin_token, program_id=program_id) + assert create_year_response.status_code in (200, 201) + program_year_id = create_year_response.json()["program_year_id"] + + update_data = program_year_data_factory( + program_id=program_id, + title_sv="Årskurs 2", + title_en="Year 2", + ) + response = client.patch( + f"/program-years/{program_year_id}", + json=update_data, + headers=auth_headers(admin_token), + ) + + assert response.status_code == 200 + data = response.json() + assert data["title_sv"] == "Årskurs 2" + assert data["title_en"] == "Year 2" + + +def test_delete_program_year_success(client, admin_token, base_program): + program_id = base_program["program_id"] + create_year_response = create_program_year(client, token=admin_token, program_id=program_id) + assert create_year_response.status_code in (200, 201) + program_year_id = create_year_response.json()["program_year_id"] + + get_response = client.get(f"/program-years/{program_year_id}") + assert get_response.status_code in (200, 201) + + response = client.delete(f"/program-years/{program_year_id}", headers=auth_headers(admin_token)) + assert response.status_code == 200 + + get_response = client.get(f"/program-years/{program_year_id}") + assert get_response.status_code == 404 + + +@pytest.mark.parametrize("token_fixture, expected_status", [("member_token", 403), (None, 401)]) +def test_create_program_year_requires_permission(client, request, token_fixture, expected_status, base_program): + token = request.getfixturevalue(token_fixture) if token_fixture else None + + response = create_program_year(client, token=token, program_id=base_program["program_id"]) + assert response.status_code == expected_status + + +def test_create_specialisation_success(client, admin_token, base_program): + program_id = base_program["program_id"] + + response = create_specialisation(client, token=admin_token, program_id=program_id) + assert response.status_code in (200, 201) + + data = response.json() + expected = specialisation_data_factory(program_id=program_id) + assert data["program_id"] == program_id + assert data["title_sv"] == expected["title_sv"] + assert "specialisation_id" in data + + +def test_create_specialisation_requires_existing_program(client, admin_token): + response = create_specialisation(client, token=admin_token, program_id=999999) + assert response.status_code == 400 + + +def test_update_specialisation_success(client, admin_token, base_program): + program_id = base_program["program_id"] + create_specialisation_response = create_specialisation(client, token=admin_token, program_id=program_id) + assert create_specialisation_response.status_code in (200, 201) + specialisation_id = create_specialisation_response.json()["specialisation_id"] + + update_data = specialisation_data_factory( + program_id=program_id, + title_sv="Datateknik", + title_en="Computer engineering", + ) + response = client.patch( + f"/specialisations/{specialisation_id}", + json=update_data, + headers=auth_headers(admin_token), + ) + + assert response.status_code == 200 + data = response.json() + assert data["title_sv"] == "Datateknik" + assert data["title_en"] == "Computer engineering" + + +def test_delete_specialisation_success(client, admin_token, base_program): + program_id = base_program["program_id"] + create_specialisation_response = create_specialisation(client, token=admin_token, program_id=program_id) + assert create_specialisation_response.status_code in (200, 201) + specialisation_id = create_specialisation_response.json()["specialisation_id"] + + get_response = client.get(f"/specialisations/{specialisation_id}") + assert get_response.status_code in (200, 201) + + response = client.delete(f"/specialisations/{specialisation_id}", headers=auth_headers(admin_token)) + assert response.status_code == 200 + + get_response = client.get(f"/specialisations/{specialisation_id}") + assert get_response.status_code == 404 + + +@pytest.mark.parametrize("token_fixture, expected_status", [("member_token", 403), (None, 401)]) +def test_create_specialisation_requires_permission(client, request, token_fixture, expected_status, base_program): + token = request.getfixturevalue(token_fixture) if token_fixture else None + + response = create_specialisation(client, token=token, program_id=base_program["program_id"]) + assert response.status_code == expected_status + + +def test_create_course_with_relationships_success(client, admin_token, plugg_relationship_ids): + payload = course_data_factory( + title="Diskret matematik", + course_code="FMAB10", + description="Relations and graphs", + program_year_ids=[plugg_relationship_ids["program_year_id"]], + specialisation_ids=[plugg_relationship_ids["specialisation_id"]], + ) + + response = create_course(client, token=admin_token, **payload) + assert response.status_code in (200, 201), response.text + + data = response.json() + assert data["title"] == "Diskret matematik" + assert data["course_code"] == "FMAB10" + assert {year["program_year_id"] for year in data["program_years"]} == {plugg_relationship_ids["program_year_id"]} + assert {spec["specialisation_id"] for spec in data["specialisations"]} == { + plugg_relationship_ids["specialisation_id"] + } + + +def test_create_course_with_missing_relationships_returns_404(client, admin_token): + payload = course_data_factory( + title="Ogiltig kurs", + course_code="FMAB11", + program_year_ids=[123456], + specialisation_ids=[654321], + ) + + response = create_course(client, token=admin_token, **payload) + assert response.status_code == 404 + + +def test_update_course_relationships_success(client, admin_token, plugg_relationship_ids): + program_id = plugg_relationship_ids["program_id"] + + year_resp = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=program_id, title_sv="Arskurs 3", title_en="Year 3"), + ) + assert year_resp.status_code in (200, 201), year_resp.text + new_program_year_id = year_resp.json()["program_year_id"] + + specialisation_resp = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(program_id=program_id, title_sv="Datavetenskap", title_en="Computer science"), + ) + assert specialisation_resp.status_code in (200, 201), specialisation_resp.text + new_specialisation_id = specialisation_resp.json()["specialisation_id"] + + create_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Flervariabelanalys", + course_code="FMAB12", + program_year_ids=[plugg_relationship_ids["program_year_id"]], + specialisation_ids=[plugg_relationship_ids["specialisation_id"]], + ), + ) + assert create_response.status_code in (200, 201), create_response.text + course_id = create_response.json()["course_id"] + + update_payload = course_data_factory( + title="Flervariabelanalys forts", + course_code="FMAB12", + description="Updated course", + program_year_ids=[new_program_year_id], + specialisation_ids=[new_specialisation_id], + ) + update_response = client.patch( + f"/courses/{course_id}", + json=update_payload, + headers=auth_headers(admin_token), + ) + + assert update_response.status_code == 200 + data = update_response.json() + assert data["title"] == "Flervariabelanalys forts" + assert {year["program_year_id"] for year in data["program_years"]} == {new_program_year_id} + assert {spec["specialisation_id"] for spec in data["specialisations"]} == {new_specialisation_id} + + +def test_course_read_endpoints_are_public(client, admin_token, plugg_relationship_ids): + create_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Signaler och system", + course_code="ETIA20", + program_year_ids=[plugg_relationship_ids["program_year_id"]], + specialisation_ids=[plugg_relationship_ids["specialisation_id"]], + ), + ) + assert create_response.status_code in (200, 201), create_response.text + course_id = create_response.json()["course_id"] + + list_response = client.get("/courses/") + assert list_response.status_code == 200 + assert any(course["course_id"] == course_id for course in list_response.json()) + + detail_response = client.get(f"/courses/{course_id}") + assert detail_response.status_code == 200 + assert detail_response.json()["course_id"] == course_id + + +@pytest.mark.parametrize("token_fixture, expected_status", [("member_token", 403), (None, 401)]) +def test_create_course_requires_permission( + client, + request, + token_fixture, + expected_status, + plugg_relationship_ids, +): + token = request.getfixturevalue(token_fixture) if token_fixture else None + + response = create_course( + client, + token=token, + **course_data_factory( + title="Operativsystem", + course_code="EDA092", + program_year_ids=[plugg_relationship_ids["program_year_id"]], + specialisation_ids=[plugg_relationship_ids["specialisation_id"]], + ), + ) + assert response.status_code == expected_status + + +def test_delete_course_success(client, admin_token, plugg_relationship_ids): + create_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Datastrukturer", + course_code="EDA123", + program_year_ids=[plugg_relationship_ids["program_year_id"]], + specialisation_ids=[plugg_relationship_ids["specialisation_id"]], + ), + ) + assert create_response.status_code in (200, 201), create_response.text + course_id = create_response.json()["course_id"] + + get_response = client.get(f"/courses/{course_id}") + assert get_response.status_code in (200, 201) + + delete_response = client.delete(f"/courses/{course_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 200 + + get_response = client.get(f"/courses/{course_id}") + assert get_response.status_code == 404 From 97feefbb564628f503b16e4c8a92a8df482aa633 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sun, 12 Apr 2026 14:55:53 +0000 Subject: [PATCH 06/26] Fix course service handling of many-to-many objects to make tests pass --- api_schemas/course_schema.py | 2 +- api_schemas/program_schema.py | 7 +-- api_schemas/program_year_schema.py | 4 +- api_schemas/specialisation_schema.py | 4 +- services/course_service.py | 70 +++++++++++++++++++--------- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py index df6e7cc..00ef233 100644 --- a/api_schemas/course_schema.py +++ b/api_schemas/course_schema.py @@ -6,13 +6,13 @@ from api_schemas.specialisation_schema import SpecialisationRead from helpers.constants import MAX_COURSE_CODE, MAX_COURSE_DESC, MAX_COURSE_TITLE from helpers.types import datetime_utc -from fastapi import UploadFile class SimpleCourseRead(BaseSchema): course_id: int title: str course_code: str | None + img_id: int | None class CourseRead(BaseSchema): diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index 8c9606e..001062c 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -5,11 +5,8 @@ from api_schemas.program_year_schema import ProgramYearRead from api_schemas.specialisation_schema import SpecialisationRead from api_schemas.course_schema import ( - CourseRead, -) # pyright: ignore[reportUnusedImport] # This is not used but is needed for the model_rebuild calls below - -ProgramYearRead.model_rebuild() -SpecialisationRead.model_rebuild() + SimpleCourseRead, # type: ignore +) # Needed for pydantic forward references in ProgramYearRead and SpecialisationRead class ProgramRead(BaseSchema): diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index 98ebb82..8148c35 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -4,7 +4,7 @@ from helpers.constants import MAX_PROGRAM_YEAR_DESC, MAX_PROGRAM_YEAR_TITLE if TYPE_CHECKING: - from api_schemas.course_schema import CourseRead + from api_schemas.course_schema import SimpleCourseRead class ProgramYearRead(BaseSchema): @@ -15,7 +15,7 @@ class ProgramYearRead(BaseSchema): description_sv: str | None description_en: str | None img_id: int | None - courses: list["CourseRead"] = [] + courses: list["SimpleCourseRead"] = [] class ProgramYearCreate(BaseSchema): diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index cec5e9e..1015cad 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -4,7 +4,7 @@ from helpers.constants import MAX_SPECIALISATION_DESC, MAX_SPECIALISATION_TITLE if TYPE_CHECKING: - from api_schemas.course_schema import CourseRead + from api_schemas.course_schema import SimpleCourseRead class SpecialisationRead(BaseSchema): @@ -15,7 +15,7 @@ class SpecialisationRead(BaseSchema): description_sv: str | None description_en: str | None img_id: int | None - courses: list["CourseRead"] = [] + courses: list["SimpleCourseRead"] = [] class SpecialisationCreate(BaseSchema): diff --git a/services/course_service.py b/services/course_service.py index 395f1df..f66ffa3 100644 --- a/services/course_service.py +++ b/services/course_service.py @@ -5,11 +5,12 @@ from database import DB_dependency from db_models.program_year_model import ProgramYear_DB from db_models.specialisation_model import Specialisation_DB +from db_models.program_year_course_model import ProgramYearCourse_DB +from db_models.specialisation_course_model import SpecialisationCourse_DB from fastapi import HTTPException, status def update_course_relationships(course: Course_DB, update_course: CourseUpdate | CourseCreate, db: DB_dependency): - # post_ids = update_posts.post_ids program_year_ids = update_course.program_year_ids specialisation_ids = update_course.specialisation_ids @@ -41,33 +42,56 @@ def update_course_relationships(course: Course_DB, update_course: CourseUpdate | status.HTTP_404_NOT_FOUND, detail=f"Specialisations with ids {missing_specialisation_ids} not found" ) - # Add new program years to the course - for program_year_id in program_year_ids: - program_year = program_years_by_id[program_year_id] - if program_year not in course.program_years: - course.program_years.append(program_year) + # Keep many-to-many joins in sync with explicit add/remove in join tables. + existing_program_year_ids = { + relation.program_year_id + for relation in db.query(ProgramYearCourse_DB).filter_by(course_id=course.course_id).all() + } + program_year_ids_to_add = [ + program_year_id for program_year_id in program_year_ids if program_year_id not in existing_program_year_ids + ] + for program_year_id in program_year_ids_to_add: + db.add(ProgramYearCourse_DB(program_year_id=program_year_id, course_id=course.course_id)) - # Add new specialisations to the course - for specialisation_id in specialisation_ids: - specialisation = specialisations_by_id[specialisation_id] - if specialisation not in course.specialisations: - course.specialisations.append(specialisation) + program_year_ids_to_remove = [ + program_year_id for program_year_id in existing_program_year_ids if program_year_id not in program_year_ids + ] + if program_year_ids_to_remove: + ( + db.query(ProgramYearCourse_DB) + .filter( + ProgramYearCourse_DB.course_id == course.course_id, + ProgramYearCourse_DB.program_year_id.in_(program_year_ids_to_remove), + ) + .delete(synchronize_session=False) + ) - # Remove program years not in the new list - program_years_to_remove = [ - program_year for program_year in course.program_years if program_year.program_year_id not in program_year_ids + existing_specialisation_ids = { + relation.specialisation_id + for relation in db.query(SpecialisationCourse_DB).filter_by(course_id=course.course_id).all() + } + specialisation_ids_to_add = [ + specialisation_id + for specialisation_id in specialisation_ids + if specialisation_id not in existing_specialisation_ids ] - for program_year in program_years_to_remove: - course.program_years.remove(program_year) + for specialisation_id in specialisation_ids_to_add: + db.add(SpecialisationCourse_DB(specialisation_id=specialisation_id, course_id=course.course_id)) - # Remove specialisations not in the new list - specialisations_to_remove = [ - specialisation - for specialisation in course.specialisations - if specialisation.specialisation_id not in specialisation_ids + specialisation_ids_to_remove = [ + specialisation_id + for specialisation_id in existing_specialisation_ids + if specialisation_id not in specialisation_ids ] - for specialisation in specialisations_to_remove: - course.specialisations.remove(specialisation) + if specialisation_ids_to_remove: + ( + db.query(SpecialisationCourse_DB) + .filter( + SpecialisationCourse_DB.course_id == course.course_id, + SpecialisationCourse_DB.specialisation_id.in_(specialisation_ids_to_remove), + ) + .delete(synchronize_session=False) + ) try: db.commit() From 6f5aee18612d6269e5a12ca8c730bae555db2fcc Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Mon, 13 Apr 2026 09:14:22 +0000 Subject: [PATCH 07/26] Update course_document to fix tests and better match how we manage regular documents --- api_schemas/course_document_schema.py | 22 ++++++++++++++++++++-- routes/course_document_router.py | 19 ++++++++++++++----- tests/test_course_documents.py | 9 +++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/api_schemas/course_document_schema.py b/api_schemas/course_document_schema.py index 9c1b47d..6fb0f5f 100644 --- a/api_schemas/course_document_schema.py +++ b/api_schemas/course_document_schema.py @@ -1,5 +1,5 @@ from typing import Annotated -from fastapi import UploadFile +from fastapi import Form from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema from helpers.constants import MAX_COURSE_DOC_AUTHOR, MAX_COURSE_DOC_SUB_CATEGORY, MAX_DOC_FILE_NAME, MAX_DOC_TITLE @@ -25,7 +25,25 @@ class CourseDocumentCreate(BaseSchema): author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] category: COURSE_DOCUMENT_CATEGORIES = "Other" sub_category: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_SUB_CATEGORY)] | None = None - file: UploadFile + + +# Apparently I have to do this to be able to send JSON forms at the same time as files in the POST course document router +def course_document_create_form( + title: str = Form(...), + file_name: str = Form(...), + course_id: int = Form(...), + author: str = Form(...), + category: COURSE_DOCUMENT_CATEGORIES = Form("Other"), + sub_category: str | None = Form(None), +) -> CourseDocumentCreate: + return CourseDocumentCreate( + title=title, + file_name=file_name, + course_id=course_id, + author=author, + category=category, + sub_category=sub_category, + ) class CourseDocumentUpdate(BaseSchema): diff --git a/routes/course_document_router.py b/routes/course_document_router.py index 8527986..2ff809e 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -1,11 +1,16 @@ import os from pathlib import Path -from fastapi import APIRouter, HTTPException, status, Response +from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status from fastapi.responses import FileResponse from sqlalchemy.exc import IntegrityError -from api_schemas.course_document_schema import CourseDocumentCreate, CourseDocumentRead, CourseDocumentUpdate +from api_schemas.course_document_schema import ( + CourseDocumentCreate, + CourseDocumentRead, + CourseDocumentUpdate, + course_document_create_form, +) from database import DB_dependency from db_models.course_document_model import CourseDocument_DB from db_models.course_model import Course_DB @@ -32,7 +37,11 @@ def get_course_document(course_document_id: int, db: DB_dependency): @course_document_router.post( "/", response_model=CourseDocumentRead, dependencies=[Permission.require("manage", "Plugg")] ) -async def create_course_document(data: CourseDocumentCreate, db: DB_dependency): +async def create_course_document( + db: DB_dependency, + data: CourseDocumentCreate = Depends(course_document_create_form), + file: UploadFile = File(), +): course = db.query(Course_DB).filter_by(course_id=data.course_id).one_or_none() if course is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Course not found") @@ -41,7 +50,7 @@ async def create_course_document(data: CourseDocumentCreate, db: DB_dependency): if base_path is None: raise HTTPException(500, detail="Document base path is not configured") - sanitized_filename, ext, file_path = await validate_file(base_path, data.file) + sanitized_filename, ext, file_path = await validate_file(base_path, file) course_document = CourseDocument_DB( title=data.title, @@ -55,7 +64,7 @@ async def create_course_document(data: CourseDocumentCreate, db: DB_dependency): try: db.add(course_document) db.commit() - file_path.write_bytes(data.file.file.read()) + file_path.write_bytes(file.file.read()) db.refresh(course_document) except IntegrityError: db.rollback() diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index 1c9085a..0169d56 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -25,18 +25,19 @@ def plugg_course_id(db_session): def test_create_course_document_success(client, admin_token, plugg_course_id, example_file): + payload = course_document_data_factory(course_id=plugg_course_id) response = create_course_document( client, token=admin_token, file=example_file, - **course_document_data_factory(course_id=plugg_course_id), + **payload, ) assert response.status_code in (200, 201), response.text data = response.json() - assert data["title"] == "Lecture notes" - assert data["author"] == "Tester" - assert data["category"] == "Notes" + assert data["title"] == payload["title"] + assert data["author"] == payload["author"] + assert data["category"] == payload["category"] assert data["course_id"] == plugg_course_id assert data["file_name"].endswith(".pdf") From 285069c14224a97624fd622e96e8d8ab219bea0b Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Mon, 13 Apr 2026 09:52:33 +0000 Subject: [PATCH 08/26] Change get all course docs route to get all course docs from a specific course --- routes/course_document_router.py | 6 +++--- tests/test_course_documents.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/routes/course_document_router.py b/routes/course_document_router.py index 2ff809e..6240e36 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -21,9 +21,9 @@ course_document_router = APIRouter() -@course_document_router.get("/", response_model=list[CourseDocumentRead]) -def get_all_course_documents(db: DB_dependency): - return db.query(CourseDocument_DB).all() +@course_document_router.get("/course/{course_id}", response_model=list[CourseDocumentRead]) +def get_all_documents_from_course(course_id: int, db: DB_dependency): + return db.query(CourseDocument_DB).filter_by(course_id=course_id).all() @course_document_router.get("/{course_document_id}", response_model=CourseDocumentRead) diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index 0169d56..d837ab9 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -89,7 +89,7 @@ def test_get_course_document_by_id_public(client, admin_token, plugg_course_id, assert response.json()["course_document_id"] == document_id -def test_get_all_course_documents_public(client, admin_token, plugg_course_id, example_file): +def test_get_all_course_documents_from_course(client, admin_token, plugg_course_id, example_file): create_response = create_course_document( client, token=admin_token, @@ -99,7 +99,7 @@ def test_get_all_course_documents_public(client, admin_token, plugg_course_id, e assert create_response.status_code in (200, 201), create_response.text document_id = create_response.json()["course_document_id"] - response = client.get("/course-documents/") + response = client.get(f"/course-documents/course/{plugg_course_id}") assert response.status_code == 200 assert any(document["course_document_id"] == document_id for document in response.json()) From 62b1a8b4ce820e9d0656fb32f6cddf0d376ea40f Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Mon, 13 Apr 2026 14:24:39 +0000 Subject: [PATCH 09/26] Finish implementation of associated images, with tests --- .devcontainer/Dockerfile | 4 +- helpers/types.py | 4 + routes/__init__.py | 3 + routes/associated_img_router.py | 60 +++++++ seed.py | 1 + services/associated_img_service.py | 116 ++++++++++++++ services/img_service.py | 2 + tests/basic_factories.py | 12 ++ tests/basic_fixtures.py | 22 +++ tests/conftest.py | 14 +- tests/test_associated_img.py | 248 +++++++++++++++++++++++++++++ 11 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 routes/associated_img_router.py create mode 100644 services/associated_img_service.py create mode 100644 tests/test_associated_img.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 292c91f..917dc3b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -28,12 +28,14 @@ ENV DOCUMENT_BASE_PATH="/workspaces/WebWebWeb/test-assets/documents" ENV COURSE_DOCUMENT_BASE_PATH="/workspaces/WebWebWeb/test-assets/course_documents" ENV ALBUM_BASE_PATH="/workspaces/WebWebWeb/test-assets/albums" ENV ASSETS_BASE_PATH="/workspaces/WebWebWeb/test-assets/assets" +ENV ASSOCIATED_IMG_BASE_PATH="/workspaces/WebWebWeb/test-assets/associated_images" ENV MOOSE_GAME_TOKEN="sad_secret_key" RUN mkdir -p "$DOCUMENT_BASE_PATH" \ "$COURSE_DOCUMENT_BASE_PATH" \ "$ALBUM_BASE_PATH" \ - "$ASSETS_BASE_PATH" + "$ASSETS_BASE_PATH" \ + "$ASSOCIATED_IMG_BASE_PATH" # In prod we must NOT run our container with root user, security risk. # In the future I think we should use non-root user event in dev to make environment similar to prod diff --git a/helpers/types.py b/helpers/types.py index 1716db8..9f217b8 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -60,6 +60,7 @@ def force_utc(date: datetime): "MailAlias", "GuildMeeting", "Plugg", + "AssociatedImg", ] # This is a little ridiculous now, but if we have many actions, this is a neat system. @@ -112,3 +113,6 @@ def force_utc(date: datetime): # Course document categories, shown under different titles on the frontend COURSE_DOCUMENT_CATEGORIES = Literal["Notes", "Summary", "Solutions", "Other"] + +# Used for associated images +ASSOCIATION_TYPES = Literal["program", "program_year", "course", "specialisation"] diff --git a/routes/__init__.py b/routes/__init__.py index 71919a7..afc8da5 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -36,6 +36,7 @@ from .specialisation_router import specialisation_router from .course_router import course_router from .course_document_router import course_document_router +from .associated_img_router import associated_img_router # here comes the big momma router main_router = APIRouter() @@ -109,3 +110,5 @@ main_router.include_router(course_router, prefix="/courses", tags=["courses"]) main_router.include_router(course_document_router, prefix="/course-documents", tags=["course documents"]) + +main_router.include_router(associated_img_router, prefix="/associated-img", tags=["associated img"]) diff --git a/routes/associated_img_router.py b/routes/associated_img_router.py new file mode 100644 index 0000000..e79ebab --- /dev/null +++ b/routes/associated_img_router.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from fastapi import Response +from database import DB_dependency, get_redis +from helpers.image_checker import validate_image +from helpers.types import ALLOWED_IMG_SIZES, ALLOWED_IMG_TYPES, ASSOCIATION_TYPES +from services.associated_img_service import upload_img, remove_img, get_single_img +from user.permission import Permission +import redis.asyncio as aioredis + +associated_img_router = APIRouter() + + +@associated_img_router.post( + "/", dependencies=[Permission.require("manage", "AssociatedImg")], response_model=dict[str, str] +) +async def upload_image( + db: DB_dependency, association_type: ASSOCIATION_TYPES, association_id: int, file: UploadFile = File() +): + await validate_image(file) + return upload_img(db, association_type, association_id, file) + + +@associated_img_router.delete( + "/{id}", dependencies=[Permission.require("manage", "AssociatedImg")], response_model=dict[str, str] +) +def delete_image(db: DB_dependency, id: int): + return remove_img(db, id) + + +@associated_img_router.get("/stream/{img_id}") +def get_image_stream( + img_id: int, + response: Response, + db: DB_dependency, +): + return get_single_img(db, img_id) + + +@associated_img_router.get("/images/{img_id}/{size}") +async def get_image( + img_id: int, + size: ALLOWED_IMG_TYPES, + response: Response, + redis: aioredis.Redis = Depends(get_redis), +) -> Response: + if size not in ALLOWED_IMG_SIZES: + raise HTTPException(status_code=400, detail="Invalid size") + + # only check Redis + path = await redis.get(f"img:{img_id}:path") + if not path: + raise HTTPException(status_code=404, detail="Image not found or not cached") + + dims = ALLOWED_IMG_SIZES[size] + internal = f"/internal/{dims}/{path.lstrip('/')}" + + return Response( + status_code=status.HTTP_200_OK, + headers={"X-Accel-Redirect": internal}, + ) diff --git a/seed.py b/seed.py index 03efece..8d94aca 100644 --- a/seed.py +++ b/seed.py @@ -221,6 +221,7 @@ def seed_permissions(db: Session, posts: list[Post_DB]): Permission(action="manage", target="UserPost", posts=["Buggmästare"]), Permission(action="view", target="GuildMeeting", posts=["Buggmästare"]), Permission(action="manage", target="GuildMeeting", posts=["Buggmästare"]), + Permission(action="manage", target="AssociatedImg", posts=["Buggmästare"]), ] [ diff --git a/services/associated_img_service.py b/services/associated_img_service.py new file mode 100644 index 0000000..907d9b8 --- /dev/null +++ b/services/associated_img_service.py @@ -0,0 +1,116 @@ +from fastapi import HTTPException, UploadFile, File +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from pathlib import Path +from db_models.associated_img_model import AssociatedImg_DB +from helpers.constants import MAX_IMG_NAME +from helpers.types import ASSOCIATION_TYPES +import random +import os +from helpers.db_util import sanitize_title +from sqlalchemy.exc import IntegrityError +from db_models.program_model import Program_DB +from db_models.program_year_model import ProgramYear_DB +from db_models.course_model import Course_DB +from db_models.specialisation_model import Specialisation_DB + + +def upload_img( + db: Session, + association_type: ASSOCIATION_TYPES, + association_id: int, + file: UploadFile = File(), +): + base_path = os.getenv("ASSOCIATED_IMG_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Server configuration error: ASSOCIATED_IMG_BASE_PATH not set") + + if file.filename is None: + raise HTTPException(400, detail="The file has no name") + + if len(file.filename) > MAX_IMG_NAME: + raise HTTPException(400, detail="The file name is too long") + + match association_type: + case "program": + association = db.query(Program_DB).filter(Program_DB.program_id == association_id).one_or_none() + case "program_year": + association = ( + db.query(ProgramYear_DB).filter(ProgramYear_DB.program_year_id == association_id).one_or_none() + ) + case "course": + association = db.query(Course_DB).filter(Course_DB.course_id == association_id).one_or_none() + case "specialisation": + association = ( + db.query(Specialisation_DB).filter(Specialisation_DB.specialisation_id == association_id).one_or_none() + ) + case _: + raise HTTPException(400, detail="Invalid association type") + if association == None: + raise HTTPException(404, detail="Associated entity for image not found") + + salt = random.getrandbits(24) + + name, ext = os.path.splitext(file.filename) + + sanitized_filename = sanitize_title(name) + + allowed_exts = {".png", ".jpg", ".jpeg", ".gif"} + + ext = ext.lower() + + if ext not in allowed_exts: + raise HTTPException(400, "file extension not allowed") + + BASE_UPLOAD_DIR = Path(f"{base_path}") + + file.filename = sanitized_filename + + file_path = Path(f"{BASE_UPLOAD_DIR}/{salt}{sanitized_filename}{ext}") + if file_path.is_file(): + raise HTTPException(409, detail="Filename is equal to already existing file") + + img = AssociatedImg_DB(path=str(file_path)) + association.img = img + + try: + db.add(img) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(400, detail="Invalid tag name") + + file_path.write_bytes(file.file.read()) + + return {"message": "File saved successfully"} + + +def remove_img(db: Session, img_id: int): + img = db.query(AssociatedImg_DB).filter(AssociatedImg_DB.associated_image_id == img_id).one_or_none() + + if img == None: + raise HTTPException(404, detail="File not found") + + if img.program is not None: + img.program.img = None + if img.program_year is not None: + img.program_year.img = None + if img.course is not None: + img.course.img = None + if img.specialisation is not None: + img.specialisation.img = None + + os.remove(img.path) + db.delete(img) + db.commit() + + return {"message": "File removed successfully"} + + +def get_single_img(db: Session, img_id: int): + img = db.query(AssociatedImg_DB).filter(AssociatedImg_DB.associated_image_id == img_id).one_or_none() + + if img == None: + raise HTTPException(404, detail="File not found") + + return FileResponse(img.path) diff --git a/services/img_service.py b/services/img_service.py index 8f3a0b4..26a9368 100644 --- a/services/img_service.py +++ b/services/img_service.py @@ -15,6 +15,8 @@ def upload_img(db: Session, album_id: int, file: UploadFile = File()): + if base_path is None: + raise HTTPException(500, detail="Server configuration error: ALBUM_BASE_PATH not set") if file.filename is None: raise HTTPException(400, detail="The file has no name") diff --git a/tests/basic_factories.py b/tests/basic_factories.py index dfab31e..a9ac8ef 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -226,3 +226,15 @@ def create_course_document(client, token=None, file=None, **kwargs): headers = auth_headers(token) if token else {} files = {"file": file} if file is not None else None return client.post("/course-documents/", data=data, files=files, headers=headers) + + +def create_associated_image(client, token=None, association_type="program", association_id=1, file=None): + """Helper to POST /associated-img/ with multipart data and optional auth.""" + headers = auth_headers(token) if token else {} + files = {"file": file} if file is not None else None + return client.post( + "/associated-img/", + params={"association_type": association_type, "association_id": association_id}, + files=files, + headers=headers, + ) diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index 1149c7b..d342df1 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -100,6 +100,7 @@ def admin_post(db_session): Permission_DB(action="manage", target="GuildMeeting"), Permission_DB(action="manage", target="Plugg"), Permission_DB(action="view", target="Plugg"), + Permission_DB(action="manage", target="AssociatedImg"), ] post.permissions.extend(permissions) db_session.commit() @@ -239,6 +240,27 @@ def example_file(): return (pdf_file, f, "application/pdf") +@pytest.fixture() +def example_image_file(): + """Creates a tiny png file and returns it as a file-like object.""" + image_file = "simple_test_image.png" + + # Might not be valid but the tests pass with this LLM generated png + png_bytes = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + with open(image_file, "wb") as f: + f.write(png_bytes) + + f = open(image_file, "rb") + return (image_file, f, "image/png") + + @pytest.fixture() def open_election(db_session): """Create and return an election that is currently open.""" diff --git a/tests/conftest.py b/tests/conftest.py index e37b8fb..711ad40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ os.environ["COURSE_DOCUMENT_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/course_documents" os.environ["ALBUM_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/albums" os.environ["ASSETS_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/assets" +os.environ["ASSOCIATED_IMG_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/associated_images" @pytest.fixture(scope="session", autouse=True) @@ -74,7 +75,16 @@ def db_session(test_engine): transaction = connection.begin() # Create all the directories - directories = ["albums", "documents", "assets/events", "assets/news", "assets/posts", "assets/users"] + directories = [ + "albums", + "documents", + "course_documents", + "associated_images", + "assets/events", + "assets/news", + "assets/posts", + "assets/users", + ] for directory in directories: os.makedirs(os.path.join("/workspaces/WebWebWeb/pytest-assets", directory), exist_ok=True) @@ -87,6 +97,8 @@ def db_session(test_engine): # Clean up the test document if os.path.exists("simple_test_document.pdf"): os.remove("simple_test_document.pdf") + if os.path.exists("simple_test_image.png"): + os.remove("simple_test_image.png") # Remove all the files in the file storage # directories are kept as is diff --git a/tests/test_associated_img.py b/tests/test_associated_img.py new file mode 100644 index 0000000..6430187 --- /dev/null +++ b/tests/test_associated_img.py @@ -0,0 +1,248 @@ +# type: ignore +import asyncio +import os + +from database import redis_client +from db_models.associated_img_model import AssociatedImg_DB + +from .basic_factories import ( + auth_headers, + create_associated_image, + create_course, + create_program, + create_program_year, + create_specialisation, +) + + +def _create_program_for_image_tests(client, admin_token): + response = create_program(client, token=admin_token) + assert response.status_code in (200, 201), response.text + return response.json()["program_id"] + + +def _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file): + upload_response = create_associated_image( + client, + token=admin_token, + association_type="program", + association_id=program_id, + file=example_image_file, + ) + assert upload_response.status_code in (200, 201), upload_response.text + + program_response = client.get(f"/programs/{program_id}") + assert program_response.status_code == 200, program_response.text + img_id = program_response.json()["img_id"] + assert img_id is not None + return img_id + + +def _create_plugg_entities_for_image_tests(client, admin_token): + program_response = create_program(client, token=admin_token) + assert program_response.status_code in (200, 201), program_response.text + program_id = program_response.json()["program_id"] + + program_year_response = create_program_year(client, token=admin_token, program_id=program_id) + assert program_year_response.status_code in (200, 201), program_year_response.text + program_year_id = program_year_response.json()["program_year_id"] + + specialisation_response = create_specialisation(client, token=admin_token, program_id=program_id) + assert specialisation_response.status_code in (200, 201), specialisation_response.text + specialisation_id = specialisation_response.json()["specialisation_id"] + + course_response = create_course( + client, + token=admin_token, + program_year_ids=[program_year_id], + specialisation_ids=[specialisation_id], + ) + assert course_response.status_code in (200, 201), course_response.text + course_id = course_response.json()["course_id"] + + return { + "program_id": program_id, + "program_year_id": program_year_id, + "specialisation_id": specialisation_id, + "course_id": course_id, + } + + +def _get_img_id_from_entity(client, association_type, association_id): + endpoint_by_type = { + "program": f"/programs/{association_id}", + "program_year": f"/program-years/{association_id}", + "course": f"/courses/{association_id}", + "specialisation": f"/specialisations/{association_id}", + } + + response = client.get(endpoint_by_type[association_type]) + assert response.status_code == 200, response.text + return response.json()["img_id"] + + +def test_admin_can_upload_associated_image(client, admin_token, db_session, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + + img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) + + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + assert image_in_db is not None + assert os.path.exists(image_in_db.path) + + +def test_unauthorized_cannot_upload_associated_image(client, admin_token, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + + response = create_associated_image( + client, + token=None, + association_type="program", + association_id=program_id, + file=example_image_file, + ) + assert response.status_code in (401, 403) + + +def test_member_cannot_upload_associated_image(client, admin_token, member_token, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + + response = create_associated_image( + client, + token=member_token, + association_type="program", + association_id=program_id, + file=example_image_file, + ) + assert response.status_code == 403 + + +def test_upload_associated_image_requires_existing_entity(client, admin_token, example_image_file): + response = create_associated_image( + client, + token=admin_token, + association_type="program", + association_id=999999, + file=example_image_file, + ) + assert response.status_code == 404 + + +def test_upload_associated_image_rejects_invalid_extension(client, admin_token): + program_id = _create_program_for_image_tests(client, admin_token) + + bad_file = ("not_an_image.txt", b"this is not an image", "text/plain") + response = create_associated_image( + client, + token=admin_token, + association_type="program", + association_id=program_id, + file=bad_file, + ) + assert response.status_code == 400 + + +def test_upload_associated_image_rejects_invalid_type(client, admin_token, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + + response = create_associated_image( + client, + token=admin_token, + association_type="invalid_type", + association_id=program_id, + file=example_image_file, + ) + assert response.status_code in (400, 422) + + +def test_get_associated_image_stream_success(client, admin_token, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) + + response = client.get(f"/associated-img/stream/{img_id}") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + + +def test_get_associated_image_internal_redirect_success(client, admin_token, db_session, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) + + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + assert image_in_db is not None + + assert redis_client is not None + asyncio.run(redis_client.set(f"img:{img_id}:path", image_in_db.path)) + + response = client.get(f"/associated-img/images/{img_id}/small") + assert response.status_code == 200 + assert response.headers["x-accel-redirect"] == f"/internal/200x200/{image_in_db.path.lstrip('/')}" + + +def test_admin_can_delete_associated_image(client, admin_token, db_session, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) + + delete_response = client.delete(f"/associated-img/{img_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 200 + + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + assert image_in_db is None + + program_response = client.get(f"/programs/{program_id}") + assert program_response.status_code == 200 + assert program_response.json()["img_id"] is None + + +def test_member_cannot_delete_associated_image(client, admin_token, member_token, example_image_file): + program_id = _create_program_for_image_tests(client, admin_token) + img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) + + response = client.delete(f"/associated-img/{img_id}", headers=auth_headers(member_token)) + assert response.status_code == 403 + + +def test_associated_image_linking_works_for_all_association_types(client, admin_token, db_session, example_image_file): + ids = _create_plugg_entities_for_image_tests(client, admin_token) + + for association_type in ("program", "program_year", "course", "specialisation"): + association_id = ids[f"{association_type}_id"] + + upload_response = create_associated_image( + client, + token=admin_token, + association_type=association_type, + association_id=association_id, + file=example_image_file, + ) + assert upload_response.status_code in (200, 201), upload_response.text + + img_id = _get_img_id_from_entity(client, association_type, association_id) + assert img_id is not None + + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + assert image_in_db is not None + + +def test_deleting_associated_image_unlinks_all_association_types(client, admin_token, example_image_file): + ids = _create_plugg_entities_for_image_tests(client, admin_token) + + for association_type in ("program", "program_year", "course", "specialisation"): + association_id = ids[f"{association_type}_id"] + + upload_response = create_associated_image( + client, + token=admin_token, + association_type=association_type, + association_id=association_id, + file=example_image_file, + ) + assert upload_response.status_code in (200, 201), upload_response.text + + img_id = _get_img_id_from_entity(client, association_type, association_id) + assert img_id is not None + + delete_response = client.delete(f"/associated-img/{img_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 200, delete_response.text + + assert _get_img_id_from_entity(client, association_type, association_id) is None From f3d699745bd09cfca3d1e48e8e25a7b8aa2f937c Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Mon, 13 Apr 2026 14:46:54 +0000 Subject: [PATCH 10/26] Change naming to be more consistent. Associated images should be referred to as associated_img. Not perfect though --- api_schemas/associated_img_schema.py | 2 +- api_schemas/course_schema.py | 4 ++-- api_schemas/program_schema.py | 2 +- api_schemas/program_year_schema.py | 2 +- api_schemas/specialisation_schema.py | 2 +- db_models/associated_img_model.py | 12 +++++++----- db_models/course_model.py | 8 ++++++-- db_models/program_model.py | 8 ++++++-- db_models/program_year_model.py | 8 ++++++-- db_models/specialisation_model.py | 8 ++++++-- services/associated_img_service.py | 14 +++++++------- tests/test_associated_img.py | 14 +++++++------- 12 files changed, 51 insertions(+), 33 deletions(-) diff --git a/api_schemas/associated_img_schema.py b/api_schemas/associated_img_schema.py index 7337c96..c40211b 100644 --- a/api_schemas/associated_img_schema.py +++ b/api_schemas/associated_img_schema.py @@ -3,7 +3,7 @@ class AssociatedImgRead(BaseSchema): - associated_image_id: int + associated_img_id: int path: str diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py index 00ef233..91e1bb0 100644 --- a/api_schemas/course_schema.py +++ b/api_schemas/course_schema.py @@ -12,7 +12,7 @@ class SimpleCourseRead(BaseSchema): course_id: int title: str course_code: str | None - img_id: int | None + associated_img_id: int | None class CourseRead(BaseSchema): @@ -20,7 +20,7 @@ class CourseRead(BaseSchema): title: str course_code: str | None description: str | None - img_id: int | None + associated_img_id: int | None documents: list[CourseDocumentRead] = [] updated_at: datetime_utc program_years: list[ProgramYearRead] = [] diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index 001062c..4cbca10 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -15,7 +15,7 @@ class ProgramRead(BaseSchema): title_en: str description_sv: str | None description_en: str | None - img_id: int | None + associated_img_id: int | None program_years: list[ProgramYearRead] = [] specialisations: list[SpecialisationRead] = [] diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index 8148c35..09cf277 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -14,7 +14,7 @@ class ProgramYearRead(BaseSchema): program_id: int description_sv: str | None description_en: str | None - img_id: int | None + associated_img_id: int | None courses: list["SimpleCourseRead"] = [] diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index 1015cad..0a9da1f 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -14,7 +14,7 @@ class SpecialisationRead(BaseSchema): program_id: int description_sv: str | None description_en: str | None - img_id: int | None + associated_img_id: int | None courses: list["SimpleCourseRead"] = [] diff --git a/db_models/associated_img_model.py b/db_models/associated_img_model.py index 6e0b4df..1e655e3 100644 --- a/db_models/associated_img_model.py +++ b/db_models/associated_img_model.py @@ -16,16 +16,18 @@ class AssociatedImg_DB(BaseModel_DB): __tablename__ = "associated_img_table" - associated_image_id: Mapped[int] = mapped_column(primary_key=True, init=False) + associated_img_id: Mapped[int] = mapped_column(primary_key=True, init=False) path: Mapped[str] = mapped_column(String(MAX_PATH_LENGTH)) - program: Mapped[Optional["Program_DB"]] = relationship(back_populates="img", init=False, uselist=False) + program: Mapped[Optional["Program_DB"]] = relationship(back_populates="associated_img", init=False, uselist=False) - program_year: Mapped[Optional["ProgramYear_DB"]] = relationship(back_populates="img", init=False, uselist=False) + program_year: Mapped[Optional["ProgramYear_DB"]] = relationship( + back_populates="associated_img", init=False, uselist=False + ) - course: Mapped[Optional["Course_DB"]] = relationship(back_populates="img", init=False, uselist=False) + course: Mapped[Optional["Course_DB"]] = relationship(back_populates="associated_img", init=False, uselist=False) specialisation: Mapped[Optional["Specialisation_DB"]] = relationship( - back_populates="img", init=False, uselist=False + back_populates="associated_img", init=False, uselist=False ) diff --git a/db_models/course_model.py b/db_models/course_model.py index e2b263a..a4ac7a2 100644 --- a/db_models/course_model.py +++ b/db_models/course_model.py @@ -43,9 +43,13 @@ class Course_DB(BaseModel_DB): target_collection="specialisation_courses", attr="specialisation", init=False ) - img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) - img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="course", init=False, uselist=False) + associated_img: Mapped[Optional["AssociatedImg_DB"]] = relationship( + back_populates="course", init=False, uselist=False + ) documents: Mapped[list["CourseDocument_DB"]] = relationship( back_populates="course", cascade="all, delete-orphan", init=False diff --git a/db_models/program_model.py b/db_models/program_model.py index fe25949..90e8672 100644 --- a/db_models/program_model.py +++ b/db_models/program_model.py @@ -23,9 +23,13 @@ class Program_DB(BaseModel_DB): description_en: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_DESC), default=None) - img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) - img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="program", init=False, uselist=False) + associated_img: Mapped[Optional["AssociatedImg_DB"]] = relationship( + back_populates="program", init=False, uselist=False + ) program_years: Mapped[list["ProgramYear_DB"]] = relationship( back_populates="program", cascade="all, delete-orphan", init=False diff --git a/db_models/program_year_model.py b/db_models/program_year_model.py index c7a55d4..fa6d8de 100644 --- a/db_models/program_year_model.py +++ b/db_models/program_year_model.py @@ -29,9 +29,13 @@ class ProgramYear_DB(BaseModel_DB): description_en: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_YEAR_DESC), default=None) - img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) - img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="program_year", init=False, uselist=False) + associated_img: Mapped[Optional["AssociatedImg_DB"]] = relationship( + back_populates="program_year", init=False, uselist=False + ) program_year_courses: Mapped[list["ProgramYearCourse_DB"]] = relationship( back_populates="program_year", cascade="all, delete-orphan", init=False diff --git a/db_models/specialisation_model.py b/db_models/specialisation_model.py index 416da31..fbb439f 100644 --- a/db_models/specialisation_model.py +++ b/db_models/specialisation_model.py @@ -29,9 +29,13 @@ class Specialisation_DB(BaseModel_DB): description_en: Mapped[Optional[str]] = mapped_column(String(MAX_SPECIALISATION_DESC), default=None) - img_id: Mapped[Optional[int]] = mapped_column(ForeignKey("associated_img_table.associated_image_id"), default=None) + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) - img: Mapped[Optional["AssociatedImg_DB"]] = relationship(back_populates="specialisation", init=False, uselist=False) + associated_img: Mapped[Optional["AssociatedImg_DB"]] = relationship( + back_populates="specialisation", init=False, uselist=False + ) specialisation_courses: Mapped[list["SpecialisationCourse_DB"]] = relationship( back_populates="specialisation", cascade="all, delete-orphan", init=False diff --git a/services/associated_img_service.py b/services/associated_img_service.py index 907d9b8..4f65cc1 100644 --- a/services/associated_img_service.py +++ b/services/associated_img_service.py @@ -71,7 +71,7 @@ def upload_img( raise HTTPException(409, detail="Filename is equal to already existing file") img = AssociatedImg_DB(path=str(file_path)) - association.img = img + association.associated_img = img try: db.add(img) @@ -86,19 +86,19 @@ def upload_img( def remove_img(db: Session, img_id: int): - img = db.query(AssociatedImg_DB).filter(AssociatedImg_DB.associated_image_id == img_id).one_or_none() + img = db.query(AssociatedImg_DB).filter(AssociatedImg_DB.associated_img_id == img_id).one_or_none() if img == None: raise HTTPException(404, detail="File not found") if img.program is not None: - img.program.img = None + img.program.associated_img = None if img.program_year is not None: - img.program_year.img = None + img.program_year.associated_img = None if img.course is not None: - img.course.img = None + img.course.associated_img = None if img.specialisation is not None: - img.specialisation.img = None + img.specialisation.associated_img = None os.remove(img.path) db.delete(img) @@ -108,7 +108,7 @@ def remove_img(db: Session, img_id: int): def get_single_img(db: Session, img_id: int): - img = db.query(AssociatedImg_DB).filter(AssociatedImg_DB.associated_image_id == img_id).one_or_none() + img = db.query(AssociatedImg_DB).filter(AssociatedImg_DB.associated_img_id == img_id).one_or_none() if img == None: raise HTTPException(404, detail="File not found") diff --git a/tests/test_associated_img.py b/tests/test_associated_img.py index 6430187..006e3bc 100644 --- a/tests/test_associated_img.py +++ b/tests/test_associated_img.py @@ -33,7 +33,7 @@ def _upload_program_image_and_get_img_id(client, admin_token, program_id, exampl program_response = client.get(f"/programs/{program_id}") assert program_response.status_code == 200, program_response.text - img_id = program_response.json()["img_id"] + img_id = program_response.json()["associated_img_id"] assert img_id is not None return img_id @@ -78,7 +78,7 @@ def _get_img_id_from_entity(client, association_type, association_id): response = client.get(endpoint_by_type[association_type]) assert response.status_code == 200, response.text - return response.json()["img_id"] + return response.json()["associated_img_id"] def test_admin_can_upload_associated_image(client, admin_token, db_session, example_image_file): @@ -86,7 +86,7 @@ def test_admin_can_upload_associated_image(client, admin_token, db_session, exam img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) - image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=img_id).one_or_none() assert image_in_db is not None assert os.path.exists(image_in_db.path) @@ -168,7 +168,7 @@ def test_get_associated_image_internal_redirect_success(client, admin_token, db_ program_id = _create_program_for_image_tests(client, admin_token) img_id = _upload_program_image_and_get_img_id(client, admin_token, program_id, example_image_file) - image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=img_id).one_or_none() assert image_in_db is not None assert redis_client is not None @@ -186,12 +186,12 @@ def test_admin_can_delete_associated_image(client, admin_token, db_session, exam delete_response = client.delete(f"/associated-img/{img_id}", headers=auth_headers(admin_token)) assert delete_response.status_code == 200 - image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=img_id).one_or_none() assert image_in_db is None program_response = client.get(f"/programs/{program_id}") assert program_response.status_code == 200 - assert program_response.json()["img_id"] is None + assert program_response.json()["associated_img_id"] is None def test_member_cannot_delete_associated_image(client, admin_token, member_token, example_image_file): @@ -220,7 +220,7 @@ def test_associated_image_linking_works_for_all_association_types(client, admin_ img_id = _get_img_id_from_entity(client, association_type, association_id) assert img_id is not None - image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_image_id=img_id).one_or_none() + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=img_id).one_or_none() assert image_in_db is not None From 33cf3602c8df1c34a20899db3a3537a8183479ce Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Tue, 14 Apr 2026 15:59:12 +0000 Subject: [PATCH 11/26] Raise limits for course titles and codes --- helpers/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/constants.py b/helpers/constants.py index c1d2618..1ed428f 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -115,8 +115,8 @@ MAX_SPECIALISATION_DESC = 10000 # Course -MAX_COURSE_TITLE = 100 -MAX_COURSE_CODE = 20 +MAX_COURSE_TITLE = 200 # University loves long course titles lol +MAX_COURSE_CODE = 100 # Sometimes people might want to put multiple codes or something MAX_COURSE_DESC = 10000 # Course document From e99cc540a94ab5db6a1a7fa0b4cae152af91dcdc Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Tue, 14 Apr 2026 15:59:56 +0000 Subject: [PATCH 12/26] Make it possible to have many programs for each specialisation by switching to a many-many relation --- api_schemas/program_schema.py | 12 +- api_schemas/specialisation_schema.py | 7 +- db_models/program_model.py | 7 +- db_models/program_specialisation_model.py | 20 ++ db_models/specialisation_model.py | 9 +- routes/course_router.py | 59 ++++- routes/specialisation_router.py | 60 ++++- services/course_service.py | 49 ++-- services/specialisation_service.py | 62 +++++ tests/basic_factories.py | 4 +- tests/test_associated_img.py | 2 +- tests/test_plugg_resources.py | 274 +++++++++++++++++++++- 12 files changed, 508 insertions(+), 57 deletions(-) create mode 100644 db_models/program_specialisation_model.py create mode 100644 services/specialisation_service.py diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index 4cbca10..58f9f0b 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -3,10 +3,18 @@ from api_schemas.base_schema import BaseSchema from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE from api_schemas.program_year_schema import ProgramYearRead -from api_schemas.specialisation_schema import SpecialisationRead from api_schemas.course_schema import ( SimpleCourseRead, # type: ignore ) # Needed for pydantic forward references in ProgramYearRead and SpecialisationRead +from api_schemas.specialisation_schema import ( + SpecialisationRead, +) + + +class SimpleProgramRead(BaseSchema): + program_id: int + title_sv: str + title_en: str class ProgramRead(BaseSchema): @@ -17,7 +25,7 @@ class ProgramRead(BaseSchema): description_en: str | None associated_img_id: int | None program_years: list[ProgramYearRead] = [] - specialisations: list[SpecialisationRead] = [] + specialisations: list["SpecialisationRead"] = [] class ProgramCreate(BaseSchema): diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index 0a9da1f..f57da5f 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -4,6 +4,7 @@ from helpers.constants import MAX_SPECIALISATION_DESC, MAX_SPECIALISATION_TITLE if TYPE_CHECKING: + from api_schemas.program_schema import SimpleProgramRead from api_schemas.course_schema import SimpleCourseRead @@ -11,7 +12,7 @@ class SpecialisationRead(BaseSchema): specialisation_id: int title_sv: str title_en: str - program_id: int + programs: list["SimpleProgramRead"] = [] description_sv: str | None description_en: str | None associated_img_id: int | None @@ -21,7 +22,7 @@ class SpecialisationRead(BaseSchema): class SpecialisationCreate(BaseSchema): title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] - program_id: int + program_ids: list[int] = [] description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None @@ -29,6 +30,6 @@ class SpecialisationCreate(BaseSchema): class SpecialisationUpdate(BaseSchema): title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] - program_id: int + program_ids: list[int] = [] description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None diff --git a/db_models/program_model.py b/db_models/program_model.py index 90e8672..727c778 100644 --- a/db_models/program_model.py +++ b/db_models/program_model.py @@ -1,6 +1,7 @@ from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE from .base_model import BaseModel_DB from sqlalchemy.orm import mapped_column, Mapped, relationship +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from typing import TYPE_CHECKING, Optional from sqlalchemy import ForeignKey, String @@ -8,6 +9,7 @@ from .associated_img_model import AssociatedImg_DB from .program_year_model import ProgramYear_DB from .specialisation_model import Specialisation_DB + from .program_specialisation_model import ProgramSpecialisation_DB class Program_DB(BaseModel_DB): @@ -35,6 +37,9 @@ class Program_DB(BaseModel_DB): back_populates="program", cascade="all, delete-orphan", init=False ) - specialisations: Mapped[list["Specialisation_DB"]] = relationship( + program_specialisations: Mapped[list["ProgramSpecialisation_DB"]] = relationship( back_populates="program", cascade="all, delete-orphan", init=False ) + specialisations: AssociationProxy[list["Specialisation_DB"]] = association_proxy( + target_collection="program_specialisations", attr="specialisation", init=False + ) diff --git a/db_models/program_specialisation_model.py b/db_models/program_specialisation_model.py new file mode 100644 index 0000000..adc33c2 --- /dev/null +++ b/db_models/program_specialisation_model.py @@ -0,0 +1,20 @@ +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING +from sqlalchemy import ForeignKey + +if TYPE_CHECKING: + from .specialisation_model import Specialisation_DB + from .program_model import Program_DB + + +class ProgramSpecialisation_DB(BaseModel_DB): + __tablename__ = "program_specialisation_table" + + program_id: Mapped[int] = mapped_column(ForeignKey("program_table.program_id"), primary_key=True) + specialisation_id: Mapped[int] = mapped_column( + ForeignKey("specialisation_table.specialisation_id"), primary_key=True + ) + + program: Mapped["Program_DB"] = relationship(back_populates="program_specialisations", init=False) + specialisation: Mapped["Specialisation_DB"] = relationship(back_populates="program_specialisations", init=False) diff --git a/db_models/specialisation_model.py b/db_models/specialisation_model.py index fbb439f..a4fda91 100644 --- a/db_models/specialisation_model.py +++ b/db_models/specialisation_model.py @@ -10,6 +10,7 @@ from .program_model import Program_DB from .specialisation_course_model import SpecialisationCourse_DB from .course_model import Course_DB + from .program_specialisation_model import ProgramSpecialisation_DB class Specialisation_DB(BaseModel_DB): @@ -21,9 +22,13 @@ class Specialisation_DB(BaseModel_DB): title_en: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE)) - program_id: Mapped[int] = mapped_column(ForeignKey("program_table.program_id")) + program_specialisations: Mapped[list["ProgramSpecialisation_DB"]] = relationship( + back_populates="specialisation", cascade="all, delete-orphan", init=False + ) - program: Mapped["Program_DB"] = relationship(back_populates="specialisations", init=False) + programs: AssociationProxy[list["Program_DB"]] = association_proxy( + target_collection="program_specialisations", attr="program", init=False + ) description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_SPECIALISATION_DESC), default=None) diff --git a/routes/course_router.py b/routes/course_router.py index 20ee2db..4e7b444 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -1,16 +1,16 @@ from fastapi import APIRouter, HTTPException, status -from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate +from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate, SimpleCourseRead from database import DB_dependency from db_models.course_model import Course_DB -from services.course_service import update_course_relationships +from services.course_service import update_course_relationships, validate_relationship_ids from user.permission import Permission course_router = APIRouter() -@course_router.get("/", response_model=list[CourseRead]) +@course_router.get("/", response_model=list[SimpleCourseRead]) def get_all_courses(db: DB_dependency): return db.query(Course_DB).all() @@ -25,14 +25,39 @@ def get_course(course_id: int, db: DB_dependency): @course_router.post("/", response_model=CourseRead, dependencies=[Permission.require("manage", "Plugg")]) def create_course(data: CourseCreate, db: DB_dependency): + if not data.program_year_ids and not data.specialisation_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="At least one program_year_id or specialisation_id must be provided to create a course", + ) + + # We have to validate before creating the course, + # otherwise we might end up with orphaned courses without valid program year or specialisation associations. + ( + missing_program_year_ids, + missing_specialisation_ids, + duplicate_program_year_ids, + duplicate_specialisation_ids, + ) = validate_relationship_ids(data, db) + if duplicate_program_year_ids or duplicate_specialisation_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate program_year_ids: {duplicate_program_year_ids}, duplicate specialisation_ids: {duplicate_specialisation_ids}", + ) + if missing_program_year_ids or missing_specialisation_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Program years with ids {missing_program_year_ids} and specialisations with ids {missing_specialisation_ids} not found, cannot create course", + ) + course = Course_DB( title=data.title, course_code=data.course_code, description=data.description, ) db.add(course) - db.commit() - db.refresh(course) + # Flush assigns course_id without committing + db.flush() # We handle program_year_ids and specialisation_ids separately, since they are many-to-many relationships. course = update_course_relationships(course, data, db) @@ -49,6 +74,30 @@ def update_course(course_id: int, data: CourseUpdate, db: DB_dependency): if course is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + if not data.program_year_ids and not data.specialisation_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="At least one program_year_id or specialisation_id must be provided to update a course", + ) + + # We have to validate before updating the course + ( + missing_program_year_ids, + missing_specialisation_ids, + duplicate_program_year_ids, + duplicate_specialisation_ids, + ) = validate_relationship_ids(data, db) + if duplicate_program_year_ids or duplicate_specialisation_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate program_year_ids: {duplicate_program_year_ids}, duplicate specialisation_ids: {duplicate_specialisation_ids}", + ) + if missing_program_year_ids or missing_specialisation_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Program years with ids {missing_program_year_ids} and specialisations with ids {missing_specialisation_ids} not found, cannot update course", + ) + for var, value in vars(data).items(): if var != "program_year_ids" and var != "specialisation_ids": # Note that we always set None values, to clear fields if the user wants to. diff --git a/routes/specialisation_router.py b/routes/specialisation_router.py index 3e3c878..5fe8a87 100644 --- a/routes/specialisation_router.py +++ b/routes/specialisation_router.py @@ -2,9 +2,9 @@ from api_schemas.specialisation_schema import SpecialisationCreate, SpecialisationRead, SpecialisationUpdate from database import DB_dependency -from db_models.program_model import Program_DB from db_models.specialisation_model import Specialisation_DB from user.permission import Permission +from services.specialisation_service import update_specialisation_program_associations, validate_program_ids specialisation_router = APIRouter() @@ -27,18 +27,39 @@ def get_specialisation(specialisation_id: int, db: DB_dependency): "/", response_model=SpecialisationRead, dependencies=[Permission.require("manage", "Plugg")] ) def create_specialisation(data: SpecialisationCreate, db: DB_dependency): - program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() - if program is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + if not data.program_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="At least one program_id must be provided to create a specialisation", + ) + + # We have to validate before creating the specialisation, + # otherwise we might end up with orphaned specialisations + missing_program_ids, duplicate_program_ids = validate_program_ids(data.program_ids, db) + if duplicate_program_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate program_ids: {duplicate_program_ids}", + ) + if missing_program_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Programs with ids {missing_program_ids} not found, cannot create specialisation", + ) specialisation = Specialisation_DB( title_sv=data.title_sv, title_en=data.title_en, - program_id=data.program_id, description_sv=data.description_sv, description_en=data.description_en, ) db.add(specialisation) + # Flush assigns specialisation_id without committing + db.flush() + + # We handle the program_ids separately, since it's a many-to-many relationship. + specialisation = update_specialisation_program_associations(specialisation, data.program_ids, db) + db.commit() db.refresh(specialisation) return specialisation @@ -52,13 +73,32 @@ def update_specialisation(specialisation_id: int, data: SpecialisationUpdate, db if specialisation is None: raise HTTPException(status.HTTP_404_NOT_FOUND) - program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() - if program is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + if not data.program_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="At least one program_id must be provided to update a specialisation", + ) + + # We have to validate before updating the specialisation + missing_program_ids, duplicate_program_ids = validate_program_ids(data.program_ids, db) + if duplicate_program_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate program_ids: {duplicate_program_ids}", + ) + if missing_program_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Programs with ids {missing_program_ids} not found, cannot update specialisation", + ) for var, value in vars(data).items(): - # Note that we always set None values, to clear fields if the user wants to. - setattr(specialisation, var, value) + if var != "program_ids": + # Note that we always set None values, to clear fields if the user wants to. + setattr(specialisation, var, value) + + # We handle the program_ids separately, since it's a many-to-many relationship. + specialisation = update_specialisation_program_associations(specialisation, data.program_ids, db) db.commit() db.refresh(specialisation) diff --git a/services/course_service.py b/services/course_service.py index f66ffa3..d46f000 100644 --- a/services/course_service.py +++ b/services/course_service.py @@ -1,5 +1,3 @@ -from sqlalchemy.exc import DataError - from api_schemas.course_schema import CourseCreate, CourseUpdate from db_models.course_model import Course_DB from database import DB_dependency @@ -7,13 +5,26 @@ from db_models.specialisation_model import Specialisation_DB from db_models.program_year_course_model import ProgramYearCourse_DB from db_models.specialisation_course_model import SpecialisationCourse_DB -from fastapi import HTTPException, status -def update_course_relationships(course: Course_DB, update_course: CourseUpdate | CourseCreate, db: DB_dependency): +def _find_duplicate_ids(ids: list[int]) -> list[int]: + seen: set[int] = set() + duplicates: list[int] = [] + for value in ids: + if value in seen and value not in duplicates: + duplicates.append(value) + continue + seen.add(value) + return duplicates + + +def validate_relationship_ids(update_course: CourseUpdate | CourseCreate, db: DB_dependency): program_year_ids = update_course.program_year_ids specialisation_ids = update_course.specialisation_ids + duplicate_program_year_ids = _find_duplicate_ids(program_year_ids) + duplicate_specialisation_ids = _find_duplicate_ids(specialisation_ids) + # Fetch all program years with the given IDs program_years = db.query(ProgramYear_DB).filter(ProgramYear_DB.program_year_id.in_(program_year_ids)).all() program_years_by_id = {program_year.program_year_id: program_year for program_year in program_years} @@ -28,19 +39,25 @@ def update_course_relationships(course: Course_DB, update_course: CourseUpdate | missing_program_year_ids = [ program_year_id for program_year_id in program_year_ids if program_year_id not in program_years_by_id ] - if missing_program_year_ids: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"Program years with ids {missing_program_year_ids} not found" - ) # Check if all specialisations exist in the database missing_specialisation_ids = [ specialisation_id for specialisation_id in specialisation_ids if specialisation_id not in specialisations_by_id ] - if missing_specialisation_ids: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"Specialisations with ids {missing_specialisation_ids} not found" - ) + + return ( + missing_program_year_ids, + missing_specialisation_ids, + duplicate_program_year_ids, + duplicate_specialisation_ids, + ) # All of these lists should be empty for a valid request, otherwise we have trouble + + +# Note: This service requires program_year_ids and specialisation_ids to already be validated, +# so check that they are all real program years and specialisations already in the database before calling this. +def update_course_relationships(course: Course_DB, update_course: CourseUpdate | CourseCreate, db: DB_dependency): + program_year_ids = update_course.program_year_ids + specialisation_ids = update_course.specialisation_ids # Keep many-to-many joins in sync with explicit add/remove in join tables. existing_program_year_ids = { @@ -93,12 +110,4 @@ def update_course_relationships(course: Course_DB, update_course: CourseUpdate | .delete(synchronize_session=False) ) - try: - db.commit() - except DataError: - db.rollback() - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail="Error updating course relationships (program years or specialisations)" - ) - return course diff --git a/services/specialisation_service.py b/services/specialisation_service.py new file mode 100644 index 0000000..b558f8b --- /dev/null +++ b/services/specialisation_service.py @@ -0,0 +1,62 @@ +from database import DB_dependency +from db_models.specialisation_model import Specialisation_DB +from db_models.program_model import Program_DB +from db_models.program_specialisation_model import ProgramSpecialisation_DB + + +def _find_duplicate_ids(ids: list[int]) -> list[int]: + seen: set[int] = set() + duplicates: list[int] = [] + for value in ids: + if value in seen and value not in duplicates: + duplicates.append(value) + continue + seen.add(value) + return duplicates + + +def validate_program_ids(program_ids: list[int], db: DB_dependency): + """Helper function to validate that all program IDs in the list exist in the database.""" + duplicate_program_ids = _find_duplicate_ids(program_ids) + + # Fetch all programs with the given IDs + programs = db.query(Program_DB).filter(Program_DB.program_id.in_(program_ids)).all() + programs_by_id = {program.program_id: program for program in programs} + + # Check if all programs exist in the database + missing_program_ids = [program_id for program_id in program_ids if program_id not in programs_by_id] + + return missing_program_ids, duplicate_program_ids # If not empty, we have trouble + + +# Note: This service requires program_ids to already be validated, +# so check that they are all real programs already in the database before calling this. +def update_specialisation_program_associations( + specialisation: Specialisation_DB, + program_ids: list[int], + db: DB_dependency, +): + + # Keep many-to-many joins in sync with explicit add/remove in join tables. + existing_program_ids = { + relation.program_id + for relation in db.query(ProgramSpecialisation_DB) + .filter_by(specialisation_id=specialisation.specialisation_id) + .all() + } + program_ids_to_add = [program_id for program_id in program_ids if program_id not in existing_program_ids] + for program_id in program_ids_to_add: + db.add(ProgramSpecialisation_DB(program_id=program_id, specialisation_id=specialisation.specialisation_id)) + + program_ids_to_remove = [program_id for program_id in existing_program_ids if program_id not in program_ids] + if program_ids_to_remove: + ( + db.query(ProgramSpecialisation_DB) + .filter( + ProgramSpecialisation_DB.specialisation_id == specialisation.specialisation_id, + ProgramSpecialisation_DB.program_id.in_(program_ids_to_remove), + ) + .delete(synchronize_session=False) + ) + + return specialisation diff --git a/tests/basic_factories.py b/tests/basic_factories.py index a9ac8ef..259e171 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -167,12 +167,12 @@ def create_program_year(client, token=None, **kwargs): return client.post("/program-years/", json=data, headers=headers) -def specialisation_data_factory(program_id=1, **kwargs): +def specialisation_data_factory(program_ids, **kwargs): """Factory for creating specialisation payloads.""" default_data = { "title_sv": "Maskininlarning", "title_en": "Machine learning", - "program_id": program_id, + "program_ids": program_ids, "description_sv": "Svensk specialiseringsbeskrivning", "description_en": "English specialisation description", } diff --git a/tests/test_associated_img.py b/tests/test_associated_img.py index 006e3bc..7327ebb 100644 --- a/tests/test_associated_img.py +++ b/tests/test_associated_img.py @@ -47,7 +47,7 @@ def _create_plugg_entities_for_image_tests(client, admin_token): assert program_year_response.status_code in (200, 201), program_year_response.text program_year_id = program_year_response.json()["program_year_id"] - specialisation_response = create_specialisation(client, token=admin_token, program_id=program_id) + specialisation_response = create_specialisation(client, token=admin_token, program_ids=[program_id]) assert specialisation_response.status_code in (200, 201), specialisation_response.text specialisation_id = specialisation_response.json()["specialisation_id"] diff --git a/tests/test_plugg_resources.py b/tests/test_plugg_resources.py index 07e6084..a6193c3 100644 --- a/tests/test_plugg_resources.py +++ b/tests/test_plugg_resources.py @@ -28,7 +28,7 @@ def plugg_relationship_ids(client, admin_token, base_program): year_resp = create_program_year(client, token=admin_token, program_id=program_id) assert year_resp.status_code in (200, 201), year_resp.text - specialisation_resp = create_specialisation(client, token=admin_token, program_id=program_id) + specialisation_resp = create_specialisation(client, token=admin_token, program_ids=[program_id]) assert specialisation_resp.status_code in (200, 201), specialisation_resp.text return { @@ -172,29 +172,29 @@ def test_create_program_year_requires_permission(client, request, token_fixture, def test_create_specialisation_success(client, admin_token, base_program): program_id = base_program["program_id"] - response = create_specialisation(client, token=admin_token, program_id=program_id) + response = create_specialisation(client, token=admin_token, program_ids=[program_id]) assert response.status_code in (200, 201) data = response.json() - expected = specialisation_data_factory(program_id=program_id) - assert data["program_id"] == program_id + expected = specialisation_data_factory(program_ids=[program_id]) + assert data["programs"][0]["program_id"] == expected["program_ids"][0] assert data["title_sv"] == expected["title_sv"] assert "specialisation_id" in data def test_create_specialisation_requires_existing_program(client, admin_token): - response = create_specialisation(client, token=admin_token, program_id=999999) - assert response.status_code == 400 + response = create_specialisation(client, token=admin_token, program_ids=[999999]) + assert response.status_code in (400, 404) def test_update_specialisation_success(client, admin_token, base_program): program_id = base_program["program_id"] - create_specialisation_response = create_specialisation(client, token=admin_token, program_id=program_id) + create_specialisation_response = create_specialisation(client, token=admin_token, program_ids=[program_id]) assert create_specialisation_response.status_code in (200, 201) specialisation_id = create_specialisation_response.json()["specialisation_id"] update_data = specialisation_data_factory( - program_id=program_id, + program_ids=[program_id], title_sv="Datateknik", title_en="Computer engineering", ) @@ -212,7 +212,7 @@ def test_update_specialisation_success(client, admin_token, base_program): def test_delete_specialisation_success(client, admin_token, base_program): program_id = base_program["program_id"] - create_specialisation_response = create_specialisation(client, token=admin_token, program_id=program_id) + create_specialisation_response = create_specialisation(client, token=admin_token, program_ids=[program_id]) assert create_specialisation_response.status_code in (200, 201) specialisation_id = create_specialisation_response.json()["specialisation_id"] @@ -226,11 +226,224 @@ def test_delete_specialisation_success(client, admin_token, base_program): assert get_response.status_code == 404 +def test_create_specialisation_with_multiple_programs_success(client, admin_token, base_program): + second_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Teknisk fysik", title_en="Engineering physics"), + ) + assert second_program_response.status_code in (200, 201), second_program_response.text + second_program_id = second_program_response.json()["program_id"] + + payload = { + "title_sv": "Inbyggda system", + "title_en": "Embedded systems", + "program_ids": [base_program["program_id"], second_program_id], + "description_sv": "Flera program", + "description_en": "Multiple programs", + } + response = client.post( + "/specialisations/", + json=payload, + headers=auth_headers(admin_token), + ) + + assert response.status_code in (200, 201), response.text + data = response.json() + assert data["title_sv"] == "Inbyggda system" + assert {program["program_id"] for program in data["programs"]} == { + base_program["program_id"], + second_program_id, + } + + +def test_update_specialisation_program_associations_success(client, admin_token, base_program): + second_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Elektroteknik", title_en="Electrical engineering"), + ) + assert second_program_response.status_code in (200, 201), second_program_response.text + second_program_id = second_program_response.json()["program_id"] + + third_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Maskinteknik", title_en="Mechanical engineering"), + ) + assert third_program_response.status_code in (200, 201), third_program_response.text + third_program_id = third_program_response.json()["program_id"] + + create_response = client.post( + "/specialisations/", + json={ + "title_sv": "AI och data", + "title_en": "AI and data", + "program_ids": [base_program["program_id"], second_program_id], + "description_sv": "Initial association", + "description_en": "Initial association", + }, + headers=auth_headers(admin_token), + ) + assert create_response.status_code in (200, 201), create_response.text + specialisation_id = create_response.json()["specialisation_id"] + + update_response = client.patch( + f"/specialisations/{specialisation_id}", + json={ + "title_sv": "AI och data uppdaterad", + "title_en": "AI and data updated", + "program_ids": [second_program_id, third_program_id], + "description_sv": "Updated association", + "description_en": "Updated association", + }, + headers=auth_headers(admin_token), + ) + + assert update_response.status_code == 200, update_response.text + updated = update_response.json() + assert updated["title_en"] == "AI and data updated" + assert {program["program_id"] for program in updated["programs"]} == { + second_program_id, + third_program_id, + } + + +def test_program_and_specialisation_reads_include_bidirectional_associations( + client, + admin_token, + base_program, + db_session, +): + second_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Industriell ekonomi", title_en="Industrial engineering"), + ) + assert second_program_response.status_code in (200, 201), second_program_response.text + second_program_id = second_program_response.json()["program_id"] + + specialisation_response = client.post( + "/specialisations/", + json={ + "title_sv": "Data science", + "title_en": "Data science", + "program_ids": [base_program["program_id"], second_program_id], + "description_sv": "Tvarkoppling", + "description_en": "Cross association", + }, + headers=auth_headers(admin_token), + ) + assert specialisation_response.status_code in (200, 201), specialisation_response.text + specialisation_id = specialisation_response.json()["specialisation_id"] + + specialisation_detail = client.get(f"/specialisations/{specialisation_id}") + assert specialisation_detail.status_code == 200, specialisation_detail.text + assert {program["program_id"] for program in specialisation_detail.json()["programs"]} == { + base_program["program_id"], + second_program_id, + } + + db_session.expire_all() + + first_program_detail = client.get(f"/programs/{base_program['program_id']}") + assert first_program_detail.status_code == 200, first_program_detail.text + assert specialisation_id in { + specialisation["specialisation_id"] for specialisation in first_program_detail.json()["specialisations"] + } + + second_program_detail = client.get(f"/programs/{second_program_id}") + assert second_program_detail.status_code == 200, second_program_detail.text + assert specialisation_id in { + specialisation["specialisation_id"] for specialisation in second_program_detail.json()["specialisations"] + } + + +def test_create_specialisation_rolls_back_when_any_program_id_is_missing(client, admin_token, base_program): + second_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Kemiteknik", title_en="Chemical engineering"), + ) + assert second_program_response.status_code in (200, 201), second_program_response.text + second_program_id = second_program_response.json()["program_id"] + + payload = specialisation_data_factory( + title_sv="Rollback specialisation", + title_en="Rollback specialisation", + program_ids=[base_program["program_id"], second_program_id, 999999], + description_sv="Should not persist", + description_en="Should not persist", + ) + + before_list = client.get("/specialisations/") + assert before_list.status_code == 200, before_list.text + before_specialisations = before_list.json() + before_count = len(before_specialisations) + + create_response = create_specialisation(client, token=admin_token, **payload) + assert create_response.status_code in (400, 404) + + after_list = client.get("/specialisations/") + assert after_list.status_code == 200, after_list.text + after_specialisations = after_list.json() + + assert len(after_specialisations) == before_count + assert all(specialisation["title_en"] != payload["title_en"] for specialisation in after_specialisations) + + +def test_update_specialisation_rolls_back_when_any_program_id_is_missing(client, admin_token, base_program): + second_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Farkostteknik", title_en="Vehicle engineering"), + ) + assert second_program_response.status_code in (200, 201), second_program_response.text + second_program_id = second_program_response.json()["program_id"] + + create_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory( + title_sv="Signalbehandling", + title_en="Signal processing", + program_ids=[base_program["program_id"], second_program_id], + ), + ) + assert create_response.status_code in (200, 201), create_response.text + created_specialisation = create_response.json() + specialisation_id = created_specialisation["specialisation_id"] + + update_payload = specialisation_data_factory( + title_sv="Signalbehandling uppdaterad", + title_en="Signal processing updated", + program_ids=[second_program_id, 999999], + description_sv="Should not persist update", + description_en="Should not persist update", + ) + update_response = client.patch( + f"/specialisations/{specialisation_id}", + json=update_payload, + headers=auth_headers(admin_token), + ) + assert update_response.status_code in (400, 404) + + detail_response = client.get(f"/specialisations/{specialisation_id}") + assert detail_response.status_code == 200, detail_response.text + detail_data = detail_response.json() + + assert detail_data["title_en"] == created_specialisation["title_en"] + assert {program["program_id"] for program in detail_data["programs"]} == { + base_program["program_id"], + second_program_id, + } + + @pytest.mark.parametrize("token_fixture, expected_status", [("member_token", 403), (None, 401)]) def test_create_specialisation_requires_permission(client, request, token_fixture, expected_status, base_program): token = request.getfixturevalue(token_fixture) if token_fixture else None - response = create_specialisation(client, token=token, program_id=base_program["program_id"]) + response = create_specialisation(client, token=token, program_ids=[base_program["program_id"]]) assert response.status_code == expected_status @@ -267,6 +480,45 @@ def test_create_course_with_missing_relationships_returns_404(client, admin_toke assert response.status_code == 404 +@pytest.mark.parametrize("invalid_field", ["program_year_ids", "specialisation_ids"]) +def test_create_course_rolls_back_when_any_relationship_id_is_missing( + client, + admin_token, + plugg_relationship_ids, + invalid_field, +): + valid_program_year_id = plugg_relationship_ids["program_year_id"] + valid_specialisation_id = plugg_relationship_ids["specialisation_id"] + + rollback_course_codes = { + "program_year_ids": "RBKPY001", + "specialisation_ids": "RBKSP001", + } + + payload = course_data_factory( + title="Rollback course", + course_code=rollback_course_codes[invalid_field], + description="Should not persist on failed relationship validation", + program_year_ids=[valid_program_year_id], + specialisation_ids=[valid_specialisation_id], + ) + payload[invalid_field] = [payload[invalid_field][0], 999999] + + before_list = client.get("/courses/") + assert before_list.status_code == 200, before_list.text + before_count = len(before_list.json()) + + response = create_course(client, token=admin_token, **payload) + assert response.status_code == 404 + + after_list = client.get("/courses/") + assert after_list.status_code == 200, after_list.text + after_courses = after_list.json() + + assert len(after_courses) == before_count + assert all(course["course_code"] != payload["course_code"] for course in after_courses) + + def test_update_course_relationships_success(client, admin_token, plugg_relationship_ids): program_id = plugg_relationship_ids["program_id"] @@ -281,7 +533,7 @@ def test_update_course_relationships_success(client, admin_token, plugg_relation specialisation_resp = create_specialisation( client, token=admin_token, - **specialisation_data_factory(program_id=program_id, title_sv="Datavetenskap", title_en="Computer science"), + **specialisation_data_factory(program_ids=[program_id], title_sv="Datavetenskap", title_en="Computer science"), ) assert specialisation_resp.status_code in (200, 201), specialisation_resp.text new_specialisation_id = specialisation_resp.json()["specialisation_id"] From 39e67b857b64539277857f6c6aa734c9238ab6f1 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Tue, 14 Apr 2026 16:56:36 +0000 Subject: [PATCH 13/26] Check for duplicate course codes and add more permissions to seeding stage --- routes/course_router.py | 17 +++++++++++++++++ seed.py | 3 +++ 2 files changed, 20 insertions(+) diff --git a/routes/course_router.py b/routes/course_router.py index 4e7b444..bd03628 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -31,6 +31,14 @@ def create_course(data: CourseCreate, db: DB_dependency): detail="At least one program_year_id or specialisation_id must be provided to create a course", ) + # Check for duplicate course code, since we require course codes to be unique. + existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).one_or_none() + if existing_course is not None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Course with course_code {data.course_code} already exists, cannot create course", + ) + # We have to validate before creating the course, # otherwise we might end up with orphaned courses without valid program year or specialisation associations. ( @@ -74,6 +82,15 @@ def update_course(course_id: int, data: CourseUpdate, db: DB_dependency): if course is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + # Check for duplicate course code, since we require course codes to be unique. + if data.course_code and data.course_code != course.course_code: + existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).one_or_none() + if existing_course is not None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Course with course_code {data.course_code} already exists, cannot update course", + ) + if not data.program_year_ids and not data.specialisation_ids: raise HTTPException( status.HTTP_400_BAD_REQUEST, diff --git a/seed.py b/seed.py index 8211693..f64edb9 100644 --- a/seed.py +++ b/seed.py @@ -223,6 +223,9 @@ def seed_permissions(db: Session, posts: list[Post_DB]): Permission(action="manage", target="GuildMeeting", posts=["Buggmästare"]), Permission(action="manage", target="AssociatedImg", posts=["Buggmästare"]), Permission(action="manage", target="Keyvals", posts=["Buggmästare"]), + Permission(action="manage", target="Plugg", posts=["Buggmästare"]), + Permission(action="manage", target="News", posts=["Buggmästare"]), + Permission(action="manage", target="Event", posts=["Buggmästare"]), ] [ From 3010b7fde90118f6f2d810e84865750c2c60c4aa Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Wed, 15 Apr 2026 12:22:51 +0000 Subject: [PATCH 14/26] disambiguate name of associated_img_router --- routes/associated_img_router.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/associated_img_router.py b/routes/associated_img_router.py index e79ebab..3d94556 100644 --- a/routes/associated_img_router.py +++ b/routes/associated_img_router.py @@ -13,7 +13,7 @@ @associated_img_router.post( "/", dependencies=[Permission.require("manage", "AssociatedImg")], response_model=dict[str, str] ) -async def upload_image( +async def upload_associated_image( db: DB_dependency, association_type: ASSOCIATION_TYPES, association_id: int, file: UploadFile = File() ): await validate_image(file) @@ -23,12 +23,12 @@ async def upload_image( @associated_img_router.delete( "/{id}", dependencies=[Permission.require("manage", "AssociatedImg")], response_model=dict[str, str] ) -def delete_image(db: DB_dependency, id: int): +def delete_associated_image(db: DB_dependency, id: int): return remove_img(db, id) @associated_img_router.get("/stream/{img_id}") -def get_image_stream( +def get_associated_image_stream( img_id: int, response: Response, db: DB_dependency, @@ -37,7 +37,7 @@ def get_image_stream( @associated_img_router.get("/images/{img_id}/{size}") -async def get_image( +async def get_associated_image( img_id: int, size: ALLOWED_IMG_TYPES, response: Response, From 17f79ec0f70e028151666e65c68932cf06a0a0d8 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Wed, 15 Apr 2026 14:12:12 +0000 Subject: [PATCH 15/26] Send error status codes (e.g. 404) to the frontend instead of just the message --- main.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 6f7ce5b..f93a40f 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,9 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request, HTTPException from fastapi.concurrency import asynccontextmanager from fastapi.routing import APIRoute from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException import database from database import init_db, session_factory from seed import seed_if_empty @@ -79,6 +81,16 @@ async def lifespan(app: FastAPI): generate_unique_id_function=generate_unique_id, ) + +# Before this was added we were only sending the detail of the error and not the status_code. +# Why? Fastapi-users overrides the default unless we fix it like this. +@app.exception_handler(HTTPException) +@app.exception_handler(StarletteHTTPException) +async def app_http_exception_handler(_: Request, exc: HTTPException | StarletteHTTPException): + payload: dict[str, int | str] = {"status_code": exc.status_code, "detail": exc.detail} + return JSONResponse(status_code=exc.status_code, content=payload, headers=exc.headers) + + if os.getenv("ENVIRONMENT") == "development": app.add_middleware( CORSMiddleware, From 067fd091ace01dd97e108b9d2c91ea77a4752a99 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Wed, 15 Apr 2026 14:26:28 +0000 Subject: [PATCH 16/26] Plugg Schemas now make more sense --- api_schemas/course_schema.py | 9 ++++----- api_schemas/program_year_schema.py | 6 ++++++ api_schemas/specialisation_schema.py | 6 ++++++ routes/course_router.py | 4 ++-- routes/specialisation_router.py | 6 +++++- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py index 91e1bb0..92ec878 100644 --- a/api_schemas/course_schema.py +++ b/api_schemas/course_schema.py @@ -2,8 +2,8 @@ from pydantic import StringConstraints from api_schemas.base_schema import BaseSchema from api_schemas.course_document_schema import CourseDocumentRead -from api_schemas.program_year_schema import ProgramYearRead -from api_schemas.specialisation_schema import SpecialisationRead +from api_schemas.program_year_schema import SimpleProgramYearRead +from api_schemas.specialisation_schema import SimpleSpecialisationRead from helpers.constants import MAX_COURSE_CODE, MAX_COURSE_DESC, MAX_COURSE_TITLE from helpers.types import datetime_utc @@ -12,7 +12,6 @@ class SimpleCourseRead(BaseSchema): course_id: int title: str course_code: str | None - associated_img_id: int | None class CourseRead(BaseSchema): @@ -23,8 +22,8 @@ class CourseRead(BaseSchema): associated_img_id: int | None documents: list[CourseDocumentRead] = [] updated_at: datetime_utc - program_years: list[ProgramYearRead] = [] - specialisations: list[SpecialisationRead] = [] + program_years: list[SimpleProgramYearRead] = [] + specialisations: list[SimpleSpecialisationRead] = [] class CourseCreate(BaseSchema): diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index 09cf277..da19edd 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -7,6 +7,12 @@ from api_schemas.course_schema import SimpleCourseRead +class SimpleProgramYearRead(BaseSchema): + program_year_id: int + title_sv: str + title_en: str + + class ProgramYearRead(BaseSchema): program_year_id: int title_sv: str diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index f57da5f..0f61354 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -8,6 +8,12 @@ from api_schemas.course_schema import SimpleCourseRead +class SimpleSpecialisationRead(BaseSchema): + specialisation_id: int + title_sv: str + title_en: str + + class SpecialisationRead(BaseSchema): specialisation_id: int title_sv: str diff --git a/routes/course_router.py b/routes/course_router.py index bd03628..99ba650 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, HTTPException, status -from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate, SimpleCourseRead +from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate from database import DB_dependency from db_models.course_model import Course_DB from services.course_service import update_course_relationships, validate_relationship_ids @@ -10,7 +10,7 @@ course_router = APIRouter() -@course_router.get("/", response_model=list[SimpleCourseRead]) +@course_router.get("/", response_model=list[CourseRead]) def get_all_courses(db: DB_dependency): return db.query(Course_DB).all() diff --git a/routes/specialisation_router.py b/routes/specialisation_router.py index 5fe8a87..9e127df 100644 --- a/routes/specialisation_router.py +++ b/routes/specialisation_router.py @@ -1,6 +1,10 @@ from fastapi import APIRouter, HTTPException, status -from api_schemas.specialisation_schema import SpecialisationCreate, SpecialisationRead, SpecialisationUpdate +from api_schemas.specialisation_schema import ( + SpecialisationCreate, + SpecialisationRead, + SpecialisationUpdate, +) from database import DB_dependency from db_models.specialisation_model import Specialisation_DB from user.permission import Permission From 8b05b97ee218b454fff745146f5dfbc1ab64d504 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Fri, 17 Apr 2026 14:07:58 +0000 Subject: [PATCH 17/26] Flip many-many plugg relationship directions to simplify frontend and calls to backend. (e.g. program now "owns" specialisations, not the other way around) --- api_schemas/course_schema.py | 4 - api_schemas/program_schema.py | 2 + api_schemas/program_year_schema.py | 2 + api_schemas/specialisation_schema.py | 4 +- db_models/course_model.py | 2 + db_models/program_model.py | 4 + db_models/program_year_model.py | 12 +- db_models/specialisation_model.py | 4 + helpers/url_formatter.py | 34 + routes/course_router.py | 94 ++- routes/program_router.py | 105 +++- routes/program_year_router.py | 120 +++- routes/specialisation_router.py | 112 +++- services/course_service.py | 113 ---- services/program_service.py | 74 +++ services/program_year_service.py | 62 ++ services/specialisation_service.py | 54 +- tests/basic_factories.py | 9 +- tests/test_associated_img.py | 23 +- tests/test_course_documents.py | 2 + tests/test_plugg_resources.py | 908 ++++++++++++++++++++------- 21 files changed, 1275 insertions(+), 469 deletions(-) create mode 100644 helpers/url_formatter.py delete mode 100644 services/course_service.py create mode 100644 services/program_service.py create mode 100644 services/program_year_service.py diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py index 92ec878..dc53a6b 100644 --- a/api_schemas/course_schema.py +++ b/api_schemas/course_schema.py @@ -30,13 +30,9 @@ class CourseCreate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None - program_year_ids: list[int] = [] - specialisation_ids: list[int] = [] class CourseUpdate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None - program_year_ids: list[int] = [] - specialisation_ids: list[int] = [] diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index 58f9f0b..47095eb 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -33,6 +33,7 @@ class ProgramCreate(BaseSchema): title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None + specialisation_ids: list[int] = [] class ProgramUpdate(BaseSchema): @@ -40,3 +41,4 @@ class ProgramUpdate(BaseSchema): title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_TITLE)] description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_DESC)] | None = None + specialisation_ids: list[int] = [] diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index da19edd..1037c6c 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -28,6 +28,7 @@ class ProgramYearCreate(BaseSchema): title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] program_id: int + course_ids: list[int] = [] description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None @@ -36,5 +37,6 @@ class ProgramYearUpdate(BaseSchema): title_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] title_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_TITLE)] program_id: int + course_ids: list[int] = [] description_sv: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_PROGRAM_YEAR_DESC)] | None = None diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index 0f61354..2d95c3f 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -28,7 +28,7 @@ class SpecialisationRead(BaseSchema): class SpecialisationCreate(BaseSchema): title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] - program_ids: list[int] = [] + course_ids: list[int] = [] description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None @@ -36,6 +36,6 @@ class SpecialisationCreate(BaseSchema): class SpecialisationUpdate(BaseSchema): title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] - program_ids: list[int] = [] + course_ids: list[int] = [] description_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None description_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_DESC)] | None = None diff --git a/db_models/course_model.py b/db_models/course_model.py index a4ac7a2..1552e1a 100644 --- a/db_models/course_model.py +++ b/db_models/course_model.py @@ -23,6 +23,8 @@ class Course_DB(BaseModel_DB): title: Mapped[str] = mapped_column(String(MAX_COURSE_TITLE)) + title_urlized: Mapped[str] = mapped_column(String(MAX_COURSE_TITLE), unique=True) + course_code: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_CODE), default=None, unique=True) description: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_DESC), default=None) diff --git a/db_models/program_model.py b/db_models/program_model.py index 727c778..24a62d4 100644 --- a/db_models/program_model.py +++ b/db_models/program_model.py @@ -19,8 +19,12 @@ class Program_DB(BaseModel_DB): title_sv: Mapped[str] = mapped_column(String(MAX_PROGRAM_TITLE)) + title_sv_urlized: Mapped[str] = mapped_column(String(MAX_PROGRAM_TITLE), unique=True) + title_en: Mapped[str] = mapped_column(String(MAX_PROGRAM_TITLE)) + title_en_urlized: Mapped[str] = mapped_column(String(MAX_PROGRAM_TITLE), unique=True) + description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_DESC), default=None) description_en: Mapped[Optional[str]] = mapped_column(String(MAX_PROGRAM_DESC), default=None) diff --git a/db_models/program_year_model.py b/db_models/program_year_model.py index fa6d8de..a1125a6 100644 --- a/db_models/program_year_model.py +++ b/db_models/program_year_model.py @@ -2,7 +2,7 @@ from .base_model import BaseModel_DB from sqlalchemy.orm import mapped_column, Mapped, relationship from typing import TYPE_CHECKING, Optional -from sqlalchemy import String, ForeignKey +from sqlalchemy import String, ForeignKey, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy if TYPE_CHECKING: @@ -14,13 +14,23 @@ class ProgramYear_DB(BaseModel_DB): __tablename__ = "program_year_table" + # See https://stackoverflow.com/questions/10059345/sqlalchemy-unique-across-multiple-columns + # Ensures that program_year titles are unique within the same program, so we can fetch by title + __table_args__ = ( + UniqueConstraint("program_id", "title_sv_urlized", name="uq_program_year_program_sv_urlized"), + UniqueConstraint("program_id", "title_en_urlized", name="uq_program_year_program_en_urlized"), + ) program_year_id: Mapped[int] = mapped_column(primary_key=True, init=False) title_sv: Mapped[str] = mapped_column(String(MAX_PROGRAM_YEAR_TITLE)) + title_sv_urlized: Mapped[str] = mapped_column(String(MAX_PROGRAM_YEAR_TITLE)) + title_en: Mapped[str] = mapped_column(String(MAX_PROGRAM_YEAR_TITLE)) + title_en_urlized: Mapped[str] = mapped_column(String(MAX_PROGRAM_YEAR_TITLE)) + program_id: Mapped[int] = mapped_column(ForeignKey("program_table.program_id")) program: Mapped["Program_DB"] = relationship(back_populates="program_years", init=False) diff --git a/db_models/specialisation_model.py b/db_models/specialisation_model.py index a4fda91..2b2d981 100644 --- a/db_models/specialisation_model.py +++ b/db_models/specialisation_model.py @@ -20,8 +20,12 @@ class Specialisation_DB(BaseModel_DB): title_sv: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE)) + title_sv_urlized: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE), unique=True) + title_en: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE)) + title_en_urlized: Mapped[str] = mapped_column(String(MAX_SPECIALISATION_TITLE), unique=True) + program_specialisations: Mapped[list["ProgramSpecialisation_DB"]] = relationship( back_populates="specialisation", cascade="all, delete-orphan", init=False ) diff --git a/helpers/url_formatter.py b/helpers/url_formatter.py new file mode 100644 index 0000000..20835fd --- /dev/null +++ b/helpers/url_formatter.py @@ -0,0 +1,34 @@ +# fmt: off +# since black formatter otherwise messes up the nice formatting of the regexes. +# type: ignore +import re + + +""" +This is meant to mirror the frontend function to enforce url-format name uniqueness. + +The frontend function: +export default function urlFormatter(value: string | number) { + return String(value) + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[åä]/g, "a") + .replace(/ö/g, "o") + .replace(/[^a-z0-9\-]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} +""" + + +def url_formatter(value: str | int) -> str: + return ( + re.sub(r'^-|-$', '', + re.sub(r'-+', '-', + re.sub(r'[^a-z0-9\-]', '', + re.sub(r'[åä]', 'a', + re.sub(r'ö', 'o', + re.sub(r'\s+', '-', + str(value).lower() + )))))) + ) diff --git a/routes/course_router.py b/routes/course_router.py index 99ba650..0277bd1 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -3,8 +3,8 @@ from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate from database import DB_dependency from db_models.course_model import Course_DB -from services.course_service import update_course_relationships, validate_relationship_ids from user.permission import Permission +from helpers.url_formatter import url_formatter course_router = APIRouter() @@ -15,6 +15,17 @@ def get_all_courses(db: DB_dependency): return db.query(Course_DB).all() +@course_router.get("/by_url_title/{title}", response_model=CourseRead) +def get_course_by_url_title(title: str, db: DB_dependency): + normalized_title = url_formatter(title) + course = db.query(Course_DB).filter_by(title_urlized=normalized_title).one_or_none() + + if course is not None: + return course + + raise HTTPException(status.HTTP_404_NOT_FOUND) + + @course_router.get("/{course_id}", response_model=CourseRead) def get_course(course_id: int, db: DB_dependency): course = db.query(Course_DB).filter_by(course_id=course_id).one_or_none() @@ -25,11 +36,13 @@ def get_course(course_id: int, db: DB_dependency): @course_router.post("/", response_model=CourseRead, dependencies=[Permission.require("manage", "Plugg")]) def create_course(data: CourseCreate, db: DB_dependency): - if not data.program_year_ids and not data.specialisation_ids: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="At least one program_year_id or specialisation_id must be provided to create a course", - ) + normalized_title = url_formatter(data.title) + + # Check for duplicate course title using url-formatter, so title uniqueness + # matches URL lookup conventions used elsewhere in plugg routes. + existing_title = db.query(Course_DB).filter_by(title_urlized=normalized_title).one_or_none() + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Course title already exists") # Check for duplicate course code, since we require course codes to be unique. existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).one_or_none() @@ -39,36 +52,13 @@ def create_course(data: CourseCreate, db: DB_dependency): detail=f"Course with course_code {data.course_code} already exists, cannot create course", ) - # We have to validate before creating the course, - # otherwise we might end up with orphaned courses without valid program year or specialisation associations. - ( - missing_program_year_ids, - missing_specialisation_ids, - duplicate_program_year_ids, - duplicate_specialisation_ids, - ) = validate_relationship_ids(data, db) - if duplicate_program_year_ids or duplicate_specialisation_ids: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Duplicate ids are not allowed. Duplicate program_year_ids: {duplicate_program_year_ids}, duplicate specialisation_ids: {duplicate_specialisation_ids}", - ) - if missing_program_year_ids or missing_specialisation_ids: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail=f"Program years with ids {missing_program_year_ids} and specialisations with ids {missing_specialisation_ids} not found, cannot create course", - ) - course = Course_DB( title=data.title, + title_urlized=normalized_title, course_code=data.course_code, description=data.description, ) db.add(course) - # Flush assigns course_id without committing - db.flush() - - # We handle program_year_ids and specialisation_ids separately, since they are many-to-many relationships. - course = update_course_relationships(course, data, db) db.commit() db.refresh(course) @@ -82,6 +72,18 @@ def update_course(course_id: int, data: CourseUpdate, db: DB_dependency): if course is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + normalized_title = url_formatter(data.title) + + # Check for duplicate course title using url-formatter. Ignore the current course when checking. + existing_title = ( + db.query(Course_DB) + .filter(Course_DB.course_id != course_id) + .filter_by(title_urlized=normalized_title) + .one_or_none() + ) + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Course title already exists") + # Check for duplicate course code, since we require course codes to be unique. if data.course_code and data.course_code != course.course_code: existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).one_or_none() @@ -91,37 +93,11 @@ def update_course(course_id: int, data: CourseUpdate, db: DB_dependency): detail=f"Course with course_code {data.course_code} already exists, cannot update course", ) - if not data.program_year_ids and not data.specialisation_ids: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="At least one program_year_id or specialisation_id must be provided to update a course", - ) - - # We have to validate before updating the course - ( - missing_program_year_ids, - missing_specialisation_ids, - duplicate_program_year_ids, - duplicate_specialisation_ids, - ) = validate_relationship_ids(data, db) - if duplicate_program_year_ids or duplicate_specialisation_ids: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Duplicate ids are not allowed. Duplicate program_year_ids: {duplicate_program_year_ids}, duplicate specialisation_ids: {duplicate_specialisation_ids}", - ) - if missing_program_year_ids or missing_specialisation_ids: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail=f"Program years with ids {missing_program_year_ids} and specialisations with ids {missing_specialisation_ids} not found, cannot update course", - ) - for var, value in vars(data).items(): - if var != "program_year_ids" and var != "specialisation_ids": - # Note that we always set None values, to clear fields if the user wants to. - setattr(course, var, value) + # Note that we always set None values, to clear fields if the user wants to. + setattr(course, var, value) - # We handle program_year_ids and specialisation_ids separately, since they are many-to-many relationships. - course = update_course_relationships(course, data, db) + course.title_urlized = normalized_title db.commit() db.refresh(course) diff --git a/routes/program_router.py b/routes/program_router.py index 960f2c1..4505f42 100644 --- a/routes/program_router.py +++ b/routes/program_router.py @@ -1,9 +1,15 @@ from fastapi import APIRouter, HTTPException, status +from sqlalchemy import or_ from api_schemas.program_schema import ProgramCreate, ProgramRead, ProgramUpdate from database import DB_dependency from db_models.program_model import Program_DB from user.permission import Permission +from helpers.url_formatter import url_formatter +from services.program_service import ( + update_program_specialisation_associations, + validate_specialisation_ids, +) program_router = APIRouter() @@ -14,6 +20,21 @@ def get_all_programs(db: DB_dependency): return db.query(Program_DB).all() +@program_router.get("/by_url_title/{title}", response_model=ProgramRead) +def get_program_by_url_title(title: str, db: DB_dependency): + normalized_title = url_formatter(title) + program = ( + db.query(Program_DB) + .filter(or_(Program_DB.title_sv_urlized == normalized_title, Program_DB.title_en_urlized == normalized_title)) + .one_or_none() + ) + + if program is not None: + return program + + raise HTTPException(status.HTTP_404_NOT_FOUND) + + @program_router.get("/{program_id}", response_model=ProgramRead) def get_program(program_id: int, db: DB_dependency): program = db.query(Program_DB).filter_by(program_id=program_id).one_or_none() @@ -24,13 +45,54 @@ def get_program(program_id: int, db: DB_dependency): @program_router.post("/", response_model=ProgramRead, dependencies=[Permission.require("manage", "Plugg")]) def create_program(data: ProgramCreate, db: DB_dependency): + missing_specialisation_ids, duplicate_specialisation_ids = validate_specialisation_ids(data.specialisation_ids, db) + if duplicate_specialisation_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate specialisation_ids: {duplicate_specialisation_ids}", + ) + if missing_specialisation_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Specialisations with ids {missing_specialisation_ids} not found, cannot create program", + ) + + # Check for conflicting titles using url-formatter so we can fetch by title later + + normalized_sv = url_formatter(data.title_sv) + normalized_en = url_formatter(data.title_en) + + normalized_titles = [normalized_sv, normalized_en] + existing_title = ( + db.query(Program_DB) + .filter( + or_( + Program_DB.title_sv_urlized.in_(normalized_titles), + Program_DB.title_en_urlized.in_(normalized_titles), + ) + ) + .first() + ) + + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Program title already exists") + program = Program_DB( title_sv=data.title_sv, + title_sv_urlized=normalized_sv, title_en=data.title_en, + title_en_urlized=normalized_en, description_sv=data.description_sv, description_en=data.description_en, ) db.add(program) + + # Flush assigns program_id without committing. + db.flush() + + # We handle specialisation_ids separately, since it's a many-to-many relationship. + program = update_program_specialisation_associations(program, data.specialisation_ids, db) + db.commit() db.refresh(program) return program @@ -42,9 +104,48 @@ def update_program(program_id: int, data: ProgramUpdate, db: DB_dependency): if program is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + missing_specialisation_ids, duplicate_specialisation_ids = validate_specialisation_ids(data.specialisation_ids, db) + if duplicate_specialisation_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate specialisation_ids: {duplicate_specialisation_ids}", + ) + if missing_specialisation_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Specialisations with ids {missing_specialisation_ids} not found, cannot update program", + ) + + normalized_sv = url_formatter(data.title_sv) + normalized_en = url_formatter(data.title_en) + + # Check for conflicting titles using url-formatter so we can fetch by title later. Ignore the current program when checking. + normalized_titles = [normalized_sv, normalized_en] + existing_title = ( + db.query(Program_DB) + .filter(Program_DB.program_id != program_id) + .filter( + or_( + Program_DB.title_sv_urlized.in_(normalized_titles), + Program_DB.title_en_urlized.in_(normalized_titles), + ) + ) + .first() + ) + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Program title already exists") + for var, value in vars(data).items(): - # Note that we always set None values, to clear fields if the user wants to. - setattr(program, var, value) + if var != "specialisation_ids": + # Note that we always set None values, to clear fields if the user wants to. + setattr(program, var, value) + + # We require title fields so these will always exist and should now be updated + program.title_sv_urlized = normalized_sv + program.title_en_urlized = normalized_en + + # We handle specialisation_ids separately, since it's a many-to-many relationship. + program = update_program_specialisation_associations(program, data.specialisation_ids, db) db.commit() db.refresh(program) diff --git a/routes/program_year_router.py b/routes/program_year_router.py index c5d0587..0f63a3c 100644 --- a/routes/program_year_router.py +++ b/routes/program_year_router.py @@ -1,10 +1,13 @@ from fastapi import APIRouter, HTTPException, status +from sqlalchemy import or_ from api_schemas.program_year_schema import ProgramYearCreate, ProgramYearRead, ProgramYearUpdate from database import DB_dependency from db_models.program_model import Program_DB from db_models.program_year_model import ProgramYear_DB from user.permission import Permission +from helpers.url_formatter import url_formatter +from services.program_year_service import update_program_year_course_associations, validate_course_ids program_year_router = APIRouter() @@ -15,6 +18,42 @@ def get_all_program_years(db: DB_dependency): return db.query(ProgramYear_DB).all() +@program_year_router.get("/by_url_title/{program_title}/{program_year_title}", response_model=ProgramYearRead) +def get_program_year_by_url_title(program_title: str, program_year_title: str, db: DB_dependency): + normalized_program_title = url_formatter(program_title) + normalized_program_year_title = url_formatter(program_year_title) + program = ( + db.query(Program_DB) + .filter( + or_( + Program_DB.title_sv_urlized == normalized_program_title, + Program_DB.title_en_urlized == normalized_program_title, + ), + ) + .one_or_none() + ) + + if program is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Program not found") + + program_year = ( + db.query(ProgramYear_DB) + .filter( + ProgramYear_DB.program_id == program.program_id, + or_( + ProgramYear_DB.title_sv_urlized == normalized_program_year_title, + ProgramYear_DB.title_en_urlized == normalized_program_year_title, + ), + ) + .one_or_none() + ) + + if program_year is not None: + return program_year + + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Program year not found") + + @program_year_router.get("/{program_year_id}", response_model=ProgramYearRead) def get_program_year(program_year_id: int, db: DB_dependency): program_year = db.query(ProgramYear_DB).filter_by(program_year_id=program_year_id).one_or_none() @@ -25,18 +64,57 @@ def get_program_year(program_year_id: int, db: DB_dependency): @program_year_router.post("/", response_model=ProgramYearRead, dependencies=[Permission.require("manage", "Plugg")]) def create_program_year(data: ProgramYearCreate, db: DB_dependency): + missing_course_ids, duplicate_course_ids = validate_course_ids(data.course_ids, db) + if duplicate_course_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate course_ids: {duplicate_course_ids}", + ) + if missing_course_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Courses with ids {missing_course_ids} not found, cannot create program year", + ) + program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() if program is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + normalized_sv = url_formatter(data.title_sv) + normalized_en = url_formatter(data.title_en) + + normalized_titles = [normalized_sv, normalized_en] + existing_title = ( + db.query(ProgramYear_DB) + .filter_by(program_id=data.program_id) + .filter( + or_( + ProgramYear_DB.title_sv_urlized.in_(normalized_titles), + ProgramYear_DB.title_en_urlized.in_(normalized_titles), + ) + ) + .first() + ) + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Program year title already exists") + program_year = ProgramYear_DB( title_sv=data.title_sv, + title_sv_urlized=normalized_sv, title_en=data.title_en, + title_en_urlized=normalized_en, program_id=data.program_id, description_sv=data.description_sv, description_en=data.description_en, ) db.add(program_year) + + # Flush assigns program_year_id without committing. + db.flush() + + # We handle course_ids separately, since it's a many-to-many relationship. + program_year = update_program_year_course_associations(program_year, data.course_ids, db) + db.commit() db.refresh(program_year) return program_year @@ -50,13 +128,51 @@ def update_program_year(program_year_id: int, data: ProgramYearUpdate, db: DB_de if program_year is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + missing_course_ids, duplicate_course_ids = validate_course_ids(data.course_ids, db) + if duplicate_course_ids: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Duplicate ids are not allowed. Duplicate course_ids: {duplicate_course_ids}", + ) + if missing_course_ids: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Courses with ids {missing_course_ids} not found, cannot update program year", + ) + program = db.query(Program_DB).filter_by(program_id=data.program_id).one_or_none() if program is None: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Program not found") + normalized_sv = url_formatter(data.title_sv) + normalized_en = url_formatter(data.title_en) + + normalized_titles = [normalized_sv, normalized_en] + existing_title = ( + db.query(ProgramYear_DB) + .filter(ProgramYear_DB.program_year_id != program_year_id) + .filter_by(program_id=data.program_id) + .filter( + or_( + ProgramYear_DB.title_sv_urlized.in_(normalized_titles), + ProgramYear_DB.title_en_urlized.in_(normalized_titles), + ) + ) + .first() + ) + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Program year title already exists") + for var, value in vars(data).items(): - # Note that we always set None values, to clear fields if the user wants to. - setattr(program_year, var, value) + if var != "course_ids": + # Note that we always set None values, to clear fields if the user wants to. + setattr(program_year, var, value) + + program_year.title_sv_urlized = normalized_sv + program_year.title_en_urlized = normalized_en + + # We handle course_ids separately, since it's a many-to-many relationship. + program_year = update_program_year_course_associations(program_year, data.course_ids, db) db.commit() db.refresh(program_year) diff --git a/routes/specialisation_router.py b/routes/specialisation_router.py index 9e127df..ba55ed0 100644 --- a/routes/specialisation_router.py +++ b/routes/specialisation_router.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, HTTPException, status +from sqlalchemy import or_ from api_schemas.specialisation_schema import ( SpecialisationCreate, @@ -8,7 +9,8 @@ from database import DB_dependency from db_models.specialisation_model import Specialisation_DB from user.permission import Permission -from services.specialisation_service import update_specialisation_program_associations, validate_program_ids +from helpers.url_formatter import url_formatter +from services.specialisation_service import update_specialisation_course_associations, validate_course_ids specialisation_router = APIRouter() @@ -19,6 +21,26 @@ def get_all_specialisations(db: DB_dependency): return db.query(Specialisation_DB).all() +@specialisation_router.get("/by_url_title/{title}", response_model=SpecialisationRead) +def get_specialisation_by_url_title(title: str, db: DB_dependency): + normalized_title = url_formatter(title) + specialisation = ( + db.query(Specialisation_DB) + .filter( + or_( + Specialisation_DB.title_sv_urlized == normalized_title, + Specialisation_DB.title_en_urlized == normalized_title, + ) + ) + .one_or_none() + ) + + if specialisation is not None: + return specialisation + + raise HTTPException(status.HTTP_404_NOT_FOUND) + + @specialisation_router.get("/{specialisation_id}", response_model=SpecialisationRead) def get_specialisation(specialisation_id: int, db: DB_dependency): specialisation = db.query(Specialisation_DB).filter_by(specialisation_id=specialisation_id).one_or_none() @@ -31,38 +53,50 @@ def get_specialisation(specialisation_id: int, db: DB_dependency): "/", response_model=SpecialisationRead, dependencies=[Permission.require("manage", "Plugg")] ) def create_specialisation(data: SpecialisationCreate, db: DB_dependency): - if not data.program_ids: + missing_course_ids, duplicate_course_ids = validate_course_ids(data.course_ids, db) + if duplicate_course_ids: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="At least one program_id must be provided to create a specialisation", + detail=f"Duplicate ids are not allowed. Duplicate course_ids: {duplicate_course_ids}", ) - - # We have to validate before creating the specialisation, - # otherwise we might end up with orphaned specialisations - missing_program_ids, duplicate_program_ids = validate_program_ids(data.program_ids, db) - if duplicate_program_ids: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Duplicate ids are not allowed. Duplicate program_ids: {duplicate_program_ids}", - ) - if missing_program_ids: + if missing_course_ids: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail=f"Programs with ids {missing_program_ids} not found, cannot create specialisation", + detail=f"Courses with ids {missing_course_ids} not found, cannot create specialisation", + ) + + normalized_sv = url_formatter(data.title_sv) + normalized_en = url_formatter(data.title_en) + + normalized_titles = [normalized_sv, normalized_en] + existing_title = ( + db.query(Specialisation_DB) + .filter( + or_( + Specialisation_DB.title_sv_urlized.in_(normalized_titles), + Specialisation_DB.title_en_urlized.in_(normalized_titles), + ) ) + .first() + ) + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Specialisation title already exists") specialisation = Specialisation_DB( title_sv=data.title_sv, + title_sv_urlized=normalized_sv, title_en=data.title_en, + title_en_urlized=normalized_en, description_sv=data.description_sv, description_en=data.description_en, ) db.add(specialisation) - # Flush assigns specialisation_id without committing + + # Flush assigns specialisation_id without committing. db.flush() - # We handle the program_ids separately, since it's a many-to-many relationship. - specialisation = update_specialisation_program_associations(specialisation, data.program_ids, db) + # We handle course_ids separately, since it's a many-to-many relationship. + specialisation = update_specialisation_course_associations(specialisation, data.course_ids, db) db.commit() db.refresh(specialisation) @@ -77,32 +111,46 @@ def update_specialisation(specialisation_id: int, data: SpecialisationUpdate, db if specialisation is None: raise HTTPException(status.HTTP_404_NOT_FOUND) - if not data.program_ids: + missing_course_ids, duplicate_course_ids = validate_course_ids(data.course_ids, db) + if duplicate_course_ids: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="At least one program_id must be provided to update a specialisation", + detail=f"Duplicate ids are not allowed. Duplicate course_ids: {duplicate_course_ids}", ) - - # We have to validate before updating the specialisation - missing_program_ids, duplicate_program_ids = validate_program_ids(data.program_ids, db) - if duplicate_program_ids: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Duplicate ids are not allowed. Duplicate program_ids: {duplicate_program_ids}", - ) - if missing_program_ids: + if missing_course_ids: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail=f"Programs with ids {missing_program_ids} not found, cannot update specialisation", + detail=f"Courses with ids {missing_course_ids} not found, cannot update specialisation", ) + normalized_sv = url_formatter(data.title_sv) + normalized_en = url_formatter(data.title_en) + + normalized_titles = [normalized_sv, normalized_en] + existing_title = ( + db.query(Specialisation_DB) + .filter(Specialisation_DB.specialisation_id != specialisation_id) + .filter( + or_( + Specialisation_DB.title_sv_urlized.in_(normalized_titles), + Specialisation_DB.title_en_urlized.in_(normalized_titles), + ) + ) + .first() + ) + if existing_title is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Specialisation title already exists") + for var, value in vars(data).items(): - if var != "program_ids": + if var != "course_ids": # Note that we always set None values, to clear fields if the user wants to. setattr(specialisation, var, value) - # We handle the program_ids separately, since it's a many-to-many relationship. - specialisation = update_specialisation_program_associations(specialisation, data.program_ids, db) + specialisation.title_sv_urlized = normalized_sv + specialisation.title_en_urlized = normalized_en + + # We handle course_ids separately, since it's a many-to-many relationship. + specialisation = update_specialisation_course_associations(specialisation, data.course_ids, db) db.commit() db.refresh(specialisation) diff --git a/services/course_service.py b/services/course_service.py deleted file mode 100644 index d46f000..0000000 --- a/services/course_service.py +++ /dev/null @@ -1,113 +0,0 @@ -from api_schemas.course_schema import CourseCreate, CourseUpdate -from db_models.course_model import Course_DB -from database import DB_dependency -from db_models.program_year_model import ProgramYear_DB -from db_models.specialisation_model import Specialisation_DB -from db_models.program_year_course_model import ProgramYearCourse_DB -from db_models.specialisation_course_model import SpecialisationCourse_DB - - -def _find_duplicate_ids(ids: list[int]) -> list[int]: - seen: set[int] = set() - duplicates: list[int] = [] - for value in ids: - if value in seen and value not in duplicates: - duplicates.append(value) - continue - seen.add(value) - return duplicates - - -def validate_relationship_ids(update_course: CourseUpdate | CourseCreate, db: DB_dependency): - program_year_ids = update_course.program_year_ids - specialisation_ids = update_course.specialisation_ids - - duplicate_program_year_ids = _find_duplicate_ids(program_year_ids) - duplicate_specialisation_ids = _find_duplicate_ids(specialisation_ids) - - # Fetch all program years with the given IDs - program_years = db.query(ProgramYear_DB).filter(ProgramYear_DB.program_year_id.in_(program_year_ids)).all() - program_years_by_id = {program_year.program_year_id: program_year for program_year in program_years} - - # Fetch all specialisations with the given IDs - specialisations = ( - db.query(Specialisation_DB).filter(Specialisation_DB.specialisation_id.in_(specialisation_ids)).all() - ) - specialisations_by_id = {specialisation.specialisation_id: specialisation for specialisation in specialisations} - - # Check if all program years exist in the database - missing_program_year_ids = [ - program_year_id for program_year_id in program_year_ids if program_year_id not in program_years_by_id - ] - - # Check if all specialisations exist in the database - missing_specialisation_ids = [ - specialisation_id for specialisation_id in specialisation_ids if specialisation_id not in specialisations_by_id - ] - - return ( - missing_program_year_ids, - missing_specialisation_ids, - duplicate_program_year_ids, - duplicate_specialisation_ids, - ) # All of these lists should be empty for a valid request, otherwise we have trouble - - -# Note: This service requires program_year_ids and specialisation_ids to already be validated, -# so check that they are all real program years and specialisations already in the database before calling this. -def update_course_relationships(course: Course_DB, update_course: CourseUpdate | CourseCreate, db: DB_dependency): - program_year_ids = update_course.program_year_ids - specialisation_ids = update_course.specialisation_ids - - # Keep many-to-many joins in sync with explicit add/remove in join tables. - existing_program_year_ids = { - relation.program_year_id - for relation in db.query(ProgramYearCourse_DB).filter_by(course_id=course.course_id).all() - } - program_year_ids_to_add = [ - program_year_id for program_year_id in program_year_ids if program_year_id not in existing_program_year_ids - ] - for program_year_id in program_year_ids_to_add: - db.add(ProgramYearCourse_DB(program_year_id=program_year_id, course_id=course.course_id)) - - program_year_ids_to_remove = [ - program_year_id for program_year_id in existing_program_year_ids if program_year_id not in program_year_ids - ] - if program_year_ids_to_remove: - ( - db.query(ProgramYearCourse_DB) - .filter( - ProgramYearCourse_DB.course_id == course.course_id, - ProgramYearCourse_DB.program_year_id.in_(program_year_ids_to_remove), - ) - .delete(synchronize_session=False) - ) - - existing_specialisation_ids = { - relation.specialisation_id - for relation in db.query(SpecialisationCourse_DB).filter_by(course_id=course.course_id).all() - } - specialisation_ids_to_add = [ - specialisation_id - for specialisation_id in specialisation_ids - if specialisation_id not in existing_specialisation_ids - ] - for specialisation_id in specialisation_ids_to_add: - db.add(SpecialisationCourse_DB(specialisation_id=specialisation_id, course_id=course.course_id)) - - specialisation_ids_to_remove = [ - specialisation_id - for specialisation_id in existing_specialisation_ids - if specialisation_id not in specialisation_ids - ] - if specialisation_ids_to_remove: - ( - db.query(SpecialisationCourse_DB) - .filter( - SpecialisationCourse_DB.course_id == course.course_id, - SpecialisationCourse_DB.specialisation_id.in_(specialisation_ids_to_remove), - ) - .delete(synchronize_session=False) - ) - - return course diff --git a/services/program_service.py b/services/program_service.py new file mode 100644 index 0000000..a99e392 --- /dev/null +++ b/services/program_service.py @@ -0,0 +1,74 @@ +from database import DB_dependency +from db_models.specialisation_model import Specialisation_DB +from db_models.program_model import Program_DB +from db_models.program_specialisation_model import ProgramSpecialisation_DB + + +def _find_duplicate_ids(ids: list[int]) -> list[int]: + seen: set[int] = set() + duplicates: list[int] = [] + for value in ids: + if value in seen and value not in duplicates: + duplicates.append(value) + continue + seen.add(value) + return duplicates + + +def validate_specialisation_ids(specialisation_ids: list[int], db: DB_dependency) -> tuple[list[int], list[int]]: + """Validate that all specialisation IDs in the list exist in the database.""" + if not specialisation_ids: + return [], [] + duplicate_specialisation_ids = _find_duplicate_ids(specialisation_ids) + + # Fetch all specialisations with the given IDs. + specialisations = ( + db.query(Specialisation_DB).filter(Specialisation_DB.specialisation_id.in_(specialisation_ids)).all() + ) + specialisations_by_id = {specialisation.specialisation_id: specialisation for specialisation in specialisations} + + # Check if all specialisations exist in the database. + missing_specialisation_ids = [ + specialisation_id for specialisation_id in specialisation_ids if specialisation_id not in specialisations_by_id + ] + + return missing_specialisation_ids, duplicate_specialisation_ids # If not empty, we should raise error + + +# Note: This service requires specialisation_ids to already be validated, +# so check that they are all real specialisations already in the database before calling this. +def update_program_specialisation_associations( + program: Program_DB, + specialisation_ids: list[int], + db: DB_dependency, +): + + # Keep many-to-many joins in sync with explicit add/remove in join tables. + existing_specialisation_ids = { + relation.specialisation_id + for relation in db.query(ProgramSpecialisation_DB).filter_by(program_id=program.program_id).all() + } + specialisation_ids_to_add = [ + specialisation_id + for specialisation_id in specialisation_ids + if specialisation_id not in existing_specialisation_ids + ] + for specialisation_id in specialisation_ids_to_add: + db.add(ProgramSpecialisation_DB(program_id=program.program_id, specialisation_id=specialisation_id)) + + specialisation_ids_to_remove = [ + specialisation_id + for specialisation_id in existing_specialisation_ids + if specialisation_id not in specialisation_ids + ] + if specialisation_ids_to_remove: + ( + db.query(ProgramSpecialisation_DB) + .filter( + ProgramSpecialisation_DB.program_id == program.program_id, + ProgramSpecialisation_DB.specialisation_id.in_(specialisation_ids_to_remove), + ) + .delete(synchronize_session=False) + ) + + return program diff --git a/services/program_year_service.py b/services/program_year_service.py new file mode 100644 index 0000000..bdf1824 --- /dev/null +++ b/services/program_year_service.py @@ -0,0 +1,62 @@ +from database import DB_dependency +from db_models.course_model import Course_DB +from db_models.program_year_model import ProgramYear_DB +from db_models.program_year_course_model import ProgramYearCourse_DB + + +def _find_duplicate_ids(ids: list[int]) -> list[int]: + seen: set[int] = set() + duplicates: list[int] = [] + for value in ids: + if value in seen and value not in duplicates: + duplicates.append(value) + continue + seen.add(value) + return duplicates + + +def validate_course_ids(course_ids: list[int], db: DB_dependency) -> tuple[list[int], list[int]]: + """Validate that all course IDs in the list exist in the database.""" + if not course_ids: + return [], [] + duplicate_course_ids = _find_duplicate_ids(course_ids) + + # Fetch all courses with the given IDs. + courses = db.query(Course_DB).filter(Course_DB.course_id.in_(course_ids)).all() + courses_by_id = {course.course_id: course for course in courses} + + # Check if all courses exist in the database. + missing_course_ids = [course_id for course_id in course_ids if course_id not in courses_by_id] + + return missing_course_ids, duplicate_course_ids + + +# Note: This service requires course_ids to already be validated, +# so check that they are all real courses already in the database before calling this. +def update_program_year_course_associations( + program_year: ProgramYear_DB, + course_ids: list[int], + db: DB_dependency, +): + + # Keep many-to-many joins in sync with explicit add/remove in join tables. + existing_course_ids = { + relation.course_id + for relation in db.query(ProgramYearCourse_DB).filter_by(program_year_id=program_year.program_year_id).all() + } + course_ids_to_add = [course_id for course_id in course_ids if course_id not in existing_course_ids] + for course_id in course_ids_to_add: + db.add(ProgramYearCourse_DB(program_year_id=program_year.program_year_id, course_id=course_id)) + + course_ids_to_remove = [course_id for course_id in existing_course_ids if course_id not in course_ids] + if course_ids_to_remove: + ( + db.query(ProgramYearCourse_DB) + .filter( + ProgramYearCourse_DB.program_year_id == program_year.program_year_id, + ProgramYearCourse_DB.course_id.in_(course_ids_to_remove), + ) + .delete(synchronize_session=False) + ) + + return program_year diff --git a/services/specialisation_service.py b/services/specialisation_service.py index b558f8b..2f4711d 100644 --- a/services/specialisation_service.py +++ b/services/specialisation_service.py @@ -1,7 +1,7 @@ from database import DB_dependency +from db_models.course_model import Course_DB from db_models.specialisation_model import Specialisation_DB -from db_models.program_model import Program_DB -from db_models.program_specialisation_model import ProgramSpecialisation_DB +from db_models.specialisation_course_model import SpecialisationCourse_DB def _find_duplicate_ids(ids: list[int]) -> list[int]: @@ -15,46 +15,48 @@ def _find_duplicate_ids(ids: list[int]) -> list[int]: return duplicates -def validate_program_ids(program_ids: list[int], db: DB_dependency): - """Helper function to validate that all program IDs in the list exist in the database.""" - duplicate_program_ids = _find_duplicate_ids(program_ids) +def validate_course_ids(course_ids: list[int], db: DB_dependency) -> tuple[list[int], list[int]]: + """Validate that all course IDs in the list exist in the database.""" + if not course_ids: + return [], [] + duplicate_course_ids = _find_duplicate_ids(course_ids) - # Fetch all programs with the given IDs - programs = db.query(Program_DB).filter(Program_DB.program_id.in_(program_ids)).all() - programs_by_id = {program.program_id: program for program in programs} + # Fetch all courses with the given IDs. + courses = db.query(Course_DB).filter(Course_DB.course_id.in_(course_ids)).all() + courses_by_id = {course.course_id: course for course in courses} - # Check if all programs exist in the database - missing_program_ids = [program_id for program_id in program_ids if program_id not in programs_by_id] + # Check if all courses exist in the database. + missing_course_ids = [course_id for course_id in course_ids if course_id not in courses_by_id] - return missing_program_ids, duplicate_program_ids # If not empty, we have trouble + return missing_course_ids, duplicate_course_ids -# Note: This service requires program_ids to already be validated, -# so check that they are all real programs already in the database before calling this. -def update_specialisation_program_associations( +# Note: This service requires course_ids to already be validated, +# so check that they are all real courses already in the database before calling this. +def update_specialisation_course_associations( specialisation: Specialisation_DB, - program_ids: list[int], + course_ids: list[int], db: DB_dependency, ): # Keep many-to-many joins in sync with explicit add/remove in join tables. - existing_program_ids = { - relation.program_id - for relation in db.query(ProgramSpecialisation_DB) + existing_course_ids = { + relation.course_id + for relation in db.query(SpecialisationCourse_DB) .filter_by(specialisation_id=specialisation.specialisation_id) .all() } - program_ids_to_add = [program_id for program_id in program_ids if program_id not in existing_program_ids] - for program_id in program_ids_to_add: - db.add(ProgramSpecialisation_DB(program_id=program_id, specialisation_id=specialisation.specialisation_id)) + course_ids_to_add = [course_id for course_id in course_ids if course_id not in existing_course_ids] + for course_id in course_ids_to_add: + db.add(SpecialisationCourse_DB(specialisation_id=specialisation.specialisation_id, course_id=course_id)) - program_ids_to_remove = [program_id for program_id in existing_program_ids if program_id not in program_ids] - if program_ids_to_remove: + course_ids_to_remove = [course_id for course_id in existing_course_ids if course_id not in course_ids] + if course_ids_to_remove: ( - db.query(ProgramSpecialisation_DB) + db.query(SpecialisationCourse_DB) .filter( - ProgramSpecialisation_DB.specialisation_id == specialisation.specialisation_id, - ProgramSpecialisation_DB.program_id.in_(program_ids_to_remove), + SpecialisationCourse_DB.specialisation_id == specialisation.specialisation_id, + SpecialisationCourse_DB.course_id.in_(course_ids_to_remove), ) .delete(synchronize_session=False) ) diff --git a/tests/basic_factories.py b/tests/basic_factories.py index 259e171..69af3f1 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -154,6 +154,7 @@ def program_year_data_factory(program_id=1, **kwargs): "title_sv": "Årskurs 1", "title_en": "Year 1", "program_id": program_id, + "course_ids": [], "description_sv": "Svensk årskursbeskrivning", "description_en": "English program year description", } @@ -167,12 +168,12 @@ def create_program_year(client, token=None, **kwargs): return client.post("/program-years/", json=data, headers=headers) -def specialisation_data_factory(program_ids, **kwargs): +def specialisation_data_factory(**kwargs): """Factory for creating specialisation payloads.""" default_data = { "title_sv": "Maskininlarning", "title_en": "Machine learning", - "program_ids": program_ids, + "course_ids": [], "description_sv": "Svensk specialiseringsbeskrivning", "description_en": "English specialisation description", } @@ -186,14 +187,12 @@ def create_specialisation(client, token=None, **kwargs): return client.post("/specialisations/", json=data, headers=headers) -def course_data_factory(program_year_ids=None, specialisation_ids=None, **kwargs): +def course_data_factory(**kwargs): """Factory for creating course payloads.""" default_data = { "title": "Lineär algebra", "course_code": "FMAA01", "description": "Hoppas du gillar matriser", - "program_year_ids": program_year_ids if program_year_ids is not None else [], - "specialisation_ids": specialisation_ids if specialisation_ids is not None else [], } return {**default_data, **kwargs} diff --git a/tests/test_associated_img.py b/tests/test_associated_img.py index 7327ebb..5fbeafc 100644 --- a/tests/test_associated_img.py +++ b/tests/test_associated_img.py @@ -43,23 +43,26 @@ def _create_plugg_entities_for_image_tests(client, admin_token): assert program_response.status_code in (200, 201), program_response.text program_id = program_response.json()["program_id"] - program_year_response = create_program_year(client, token=admin_token, program_id=program_id) - assert program_year_response.status_code in (200, 201), program_year_response.text - program_year_id = program_year_response.json()["program_year_id"] - - specialisation_response = create_specialisation(client, token=admin_token, program_ids=[program_id]) - assert specialisation_response.status_code in (200, 201), specialisation_response.text - specialisation_id = specialisation_response.json()["specialisation_id"] - course_response = create_course( client, token=admin_token, - program_year_ids=[program_year_id], - specialisation_ids=[specialisation_id], ) assert course_response.status_code in (200, 201), course_response.text course_id = course_response.json()["course_id"] + program_year_response = create_program_year( + client, + token=admin_token, + program_id=program_id, + course_ids=[course_id], + ) + assert program_year_response.status_code in (200, 201), program_year_response.text + program_year_id = program_year_response.json()["program_year_id"] + + specialisation_response = create_specialisation(client, token=admin_token, course_ids=[course_id]) + assert specialisation_response.status_code in (200, 201), specialisation_response.text + specialisation_id = specialisation_response.json()["specialisation_id"] + return { "program_id": program_id, "program_year_id": program_year_id, diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index d837ab9..14c912e 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -7,6 +7,7 @@ course_document_data_factory, create_course_document, ) +from helpers.url_formatter import url_formatter @pytest.fixture @@ -15,6 +16,7 @@ def plugg_course_id(db_session): course = Course_DB( title="Grundkurs i programmering", + title_urlized=url_formatter("Grundkurs i programmering"), course_code="EDAA01", description="Test course for course-document tests", ) diff --git a/tests/test_plugg_resources.py b/tests/test_plugg_resources.py index a6193c3..47b228a 100644 --- a/tests/test_plugg_resources.py +++ b/tests/test_plugg_resources.py @@ -13,6 +13,8 @@ specialisation_data_factory, ) +from helpers.url_formatter import url_formatter + @pytest.fixture def base_program(client, admin_token): @@ -23,18 +25,33 @@ def base_program(client, admin_token): @pytest.fixture def plugg_relationship_ids(client, admin_token, base_program): + # Create a program year and specialisation, then link them to the program, so we can test course creation with relationships in one go. program_id = base_program["program_id"] year_resp = create_program_year(client, token=admin_token, program_id=program_id) assert year_resp.status_code in (200, 201), year_resp.text - specialisation_resp = create_specialisation(client, token=admin_token, program_ids=[program_id]) + specialisation_resp = create_specialisation(client, token=admin_token) assert specialisation_resp.status_code in (200, 201), specialisation_resp.text + specialisation_id = specialisation_resp.json()["specialisation_id"] + + link_response = client.patch( + f"/programs/{program_id}", + json=program_data_factory( + title_sv=base_program["title_sv"], + title_en=base_program["title_en"], + description_sv=base_program["description_sv"], + description_en=base_program["description_en"], + specialisation_ids=[specialisation_id], + ), + headers=auth_headers(admin_token), + ) + assert link_response.status_code == 200, link_response.text return { "program_id": program_id, "program_year_id": year_resp.json()["program_year_id"], - "specialisation_id": specialisation_resp.json()["specialisation_id"], + "specialisation_id": specialisation_id, } @@ -71,6 +88,20 @@ def test_program_read_endpoints_are_public(client, admin_token): assert detail_response.json()["program_id"] == program_id +def test_program_get_by_url_title_success(client, admin_token): + create_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Datateknik", title_en="Computer engineering"), + ) + assert create_response.status_code in (200, 201), create_response.text + program_id = create_response.json()["program_id"] + + by_url_response = client.get("/programs/by_url_title/computer-engineering") + assert by_url_response.status_code == 200, by_url_response.text + assert by_url_response.json()["program_id"] == program_id + + def test_update_program_success(client, admin_token): create_response = create_program(client, token=admin_token) assert create_response.status_code in (200, 201) @@ -89,6 +120,60 @@ def test_update_program_success(client, admin_token): assert data["title_en"] == "Updated program" +def test_program_cross_language_urlized_title_collision_returns_409(client, admin_token): + first_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Datateknik", + title_en="Computer engineering", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Computer engineering", + title_en="Program B", + ), + ) + assert second_response.status_code == 409 + + +def test_program_cross_language_collision_with_two_existing_programs_returns_409(client, admin_token): + first_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Datateknik", + title_en="Program A", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Reglersystem", + title_en="Signal processing", + ), + ) + assert second_response.status_code in (200, 201), second_response.text + + colliding_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Datateknik", + title_en="Signal processing", + ), + ) + assert colliding_response.status_code == 409 + + def test_delete_program_success(client, admin_token): create_response = create_program(client, token=admin_token) assert create_response.status_code in (200, 201) @@ -169,32 +254,159 @@ def test_create_program_year_requires_permission(client, request, token_fixture, assert response.status_code == expected_status -def test_create_specialisation_success(client, admin_token, base_program): +def test_program_year_get_by_url_title_success(client, admin_token, base_program): program_id = base_program["program_id"] + create_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory( + program_id=program_id, + title_sv="Årskurs 1", + title_en="Year one", + ), + ) + assert create_response.status_code in (200, 201), create_response.text + program_year_id = create_response.json()["program_year_id"] + + by_url_response = client.get(f"/program-years/by_url_title/{url_formatter(base_program['title_sv'])}/arskurs-1") + assert by_url_response.status_code == 200, by_url_response.text + assert by_url_response.json()["program_year_id"] == program_year_id - response = create_specialisation(client, token=admin_token, program_ids=[program_id]) + +def test_program_year_duplicate_urlized_title_same_program_returns_409(client, admin_token, base_program): + program_id = base_program["program_id"] + first_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=program_id, title_sv="Årskurs 2", title_en="Year two"), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=program_id, title_sv="Arskurs 2!!!", title_en="Year två"), + ) + assert second_response.status_code == 409 + + +def test_program_year_cross_language_urlized_title_collision_same_program_returns_409( + client, admin_token, base_program +): + program_id = base_program["program_id"] + first_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory( + program_id=program_id, + title_sv="Årskurs 5", + title_en="Year five", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory( + program_id=program_id, + title_sv="Year five", + title_en="Årskurs 6", + ), + ) + assert second_response.status_code == 409 + + +def test_program_year_duplicate_urlized_title_different_program_allowed(client, admin_token, base_program): + first_program_id = base_program["program_id"] + second_program_response = create_program( + client, + token=admin_token, + **program_data_factory(title_sv="Väg och vatten", title_en="Civil engineering"), + ) + assert second_program_response.status_code in (200, 201), second_program_response.text + second_program_id = second_program_response.json()["program_id"] + + first_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=first_program_id, title_sv="Årskurs 3", title_en="Year three"), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=second_program_id, title_sv="Arskurs 3", title_en="Year three"), + ) + assert second_response.status_code in (200, 201), second_response.text + + second_program_year_id = second_response.json()["program_year_id"] + by_url_response = client.get( + f"/program-years/by_url_title/{url_formatter(second_program_response.json()['title_sv'])}/arskurs-3" + ) + assert by_url_response.status_code == 200, by_url_response.text + assert by_url_response.json()["program_year_id"] == second_program_year_id + + +def test_create_specialisation_success(client, admin_token): + response = create_specialisation(client, token=admin_token) assert response.status_code in (200, 201) data = response.json() - expected = specialisation_data_factory(program_ids=[program_id]) - assert data["programs"][0]["program_id"] == expected["program_ids"][0] + expected = specialisation_data_factory() + assert data["programs"] == [] assert data["title_sv"] == expected["title_sv"] assert "specialisation_id" in data -def test_create_specialisation_requires_existing_program(client, admin_token): - response = create_specialisation(client, token=admin_token, program_ids=[999999]) - assert response.status_code in (400, 404) +def test_specialisation_get_by_url_title_success(client, admin_token): + create_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Signalbehandling", title_en="Signal processing"), + ) + assert create_response.status_code in (200, 201), create_response.text + specialisation_id = create_response.json()["specialisation_id"] + by_url_response = client.get("/specialisations/by_url_title/signal-processing") + assert by_url_response.status_code == 200, by_url_response.text + assert by_url_response.json()["specialisation_id"] == specialisation_id -def test_update_specialisation_success(client, admin_token, base_program): - program_id = base_program["program_id"] - create_specialisation_response = create_specialisation(client, token=admin_token, program_ids=[program_id]) + +def test_specialisation_cross_language_urlized_title_collision_returns_409(client, admin_token): + first_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory( + title_sv="Reglersystem", + title_en="Control systems", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory( + title_sv="Control systems", + title_en="Specialisering B", + ), + ) + assert second_response.status_code == 409 + + +def test_create_program_requires_existing_specialisation(client, admin_token): + response = create_program(client, token=admin_token, specialisation_ids=[999999]) + assert response.status_code == 404 + + +def test_update_specialisation_success(client, admin_token): + create_specialisation_response = create_specialisation(client, token=admin_token) assert create_specialisation_response.status_code in (200, 201) specialisation_id = create_specialisation_response.json()["specialisation_id"] update_data = specialisation_data_factory( - program_ids=[program_id], title_sv="Datateknik", title_en="Computer engineering", ) @@ -210,9 +422,8 @@ def test_update_specialisation_success(client, admin_token, base_program): assert data["title_en"] == "Computer engineering" -def test_delete_specialisation_success(client, admin_token, base_program): - program_id = base_program["program_id"] - create_specialisation_response = create_specialisation(client, token=admin_token, program_ids=[program_id]) +def test_delete_specialisation_success(client, admin_token): + create_specialisation_response = create_specialisation(client, token=admin_token) assert create_specialisation_response.status_code in (200, 201) specialisation_id = create_specialisation_response.json()["specialisation_id"] @@ -226,74 +437,85 @@ def test_delete_specialisation_success(client, admin_token, base_program): assert get_response.status_code == 404 -def test_create_specialisation_with_multiple_programs_success(client, admin_token, base_program): - second_program_response = create_program( +def test_create_program_with_multiple_specialisations_success(client, admin_token): + first_specialisation_response = create_specialisation( client, token=admin_token, - **program_data_factory(title_sv="Teknisk fysik", title_en="Engineering physics"), + **specialisation_data_factory(title_sv="Inbyggda system", title_en="Embedded systems"), ) - assert second_program_response.status_code in (200, 201), second_program_response.text - second_program_id = second_program_response.json()["program_id"] + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] - payload = { - "title_sv": "Inbyggda system", - "title_en": "Embedded systems", - "program_ids": [base_program["program_id"], second_program_id], - "description_sv": "Flera program", - "description_en": "Multiple programs", - } - response = client.post( - "/specialisations/", - json=payload, - headers=auth_headers(admin_token), + second_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Reglersystem", title_en="Control systems"), + ) + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] + + response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Teknisk fysik", + title_en="Engineering physics", + specialisation_ids=[first_specialisation_id, second_specialisation_id], + ), ) assert response.status_code in (200, 201), response.text data = response.json() - assert data["title_sv"] == "Inbyggda system" - assert {program["program_id"] for program in data["programs"]} == { - base_program["program_id"], - second_program_id, + assert data["title_sv"] == "Teknisk fysik" + assert {specialisation["specialisation_id"] for specialisation in data["specialisations"]} == { + first_specialisation_id, + second_specialisation_id, } -def test_update_specialisation_program_associations_success(client, admin_token, base_program): - second_program_response = create_program( +def test_update_program_specialisation_associations_success(client, admin_token): + first_specialisation_response = create_specialisation( client, token=admin_token, - **program_data_factory(title_sv="Elektroteknik", title_en="Electrical engineering"), + **specialisation_data_factory(title_sv="AI och data", title_en="AI and data"), ) - assert second_program_response.status_code in (200, 201), second_program_response.text - second_program_id = second_program_response.json()["program_id"] + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] - third_program_response = create_program( + second_specialisation_response = create_specialisation( client, token=admin_token, - **program_data_factory(title_sv="Maskinteknik", title_en="Mechanical engineering"), + **specialisation_data_factory(title_sv="Signalbehandling", title_en="Signal processing"), ) - assert third_program_response.status_code in (200, 201), third_program_response.text - third_program_id = third_program_response.json()["program_id"] + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] - create_response = client.post( - "/specialisations/", - json={ - "title_sv": "AI och data", - "title_en": "AI and data", - "program_ids": [base_program["program_id"], second_program_id], - "description_sv": "Initial association", - "description_en": "Initial association", - }, - headers=auth_headers(admin_token), + third_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Nätverk", title_en="Networks"), + ) + assert third_specialisation_response.status_code in (200, 201), third_specialisation_response.text + third_specialisation_id = third_specialisation_response.json()["specialisation_id"] + + create_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Datateknik", + title_en="Computer engineering", + specialisation_ids=[first_specialisation_id, second_specialisation_id], + ), ) assert create_response.status_code in (200, 201), create_response.text - specialisation_id = create_response.json()["specialisation_id"] + program_id = create_response.json()["program_id"] update_response = client.patch( - f"/specialisations/{specialisation_id}", + f"/programs/{program_id}", json={ - "title_sv": "AI och data uppdaterad", - "title_en": "AI and data updated", - "program_ids": [second_program_id, third_program_id], + "title_sv": "Datateknik uppdaterad", + "title_en": "Computer engineering updated", + "specialisation_ids": [second_specialisation_id, third_specialisation_id], "description_sv": "Updated association", "description_en": "Updated association", }, @@ -302,158 +524,174 @@ def test_update_specialisation_program_associations_success(client, admin_token, assert update_response.status_code == 200, update_response.text updated = update_response.json() - assert updated["title_en"] == "AI and data updated" - assert {program["program_id"] for program in updated["programs"]} == { - second_program_id, - third_program_id, + assert updated["title_en"] == "Computer engineering updated" + assert {specialisation["specialisation_id"] for specialisation in updated["specialisations"]} == { + second_specialisation_id, + third_specialisation_id, } -def test_program_and_specialisation_reads_include_bidirectional_associations( +def test_program_reads_include_specialisation_associations( client, admin_token, - base_program, db_session, ): - second_program_response = create_program( + first_specialisation_response = create_specialisation( client, token=admin_token, - **program_data_factory(title_sv="Industriell ekonomi", title_en="Industrial engineering"), + **specialisation_data_factory(title_sv="Data science", title_en="Data science"), ) - assert second_program_response.status_code in (200, 201), second_program_response.text - second_program_id = second_program_response.json()["program_id"] + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] - specialisation_response = client.post( - "/specialisations/", - json={ - "title_sv": "Data science", - "title_en": "Data science", - "program_ids": [base_program["program_id"], second_program_id], - "description_sv": "Tvarkoppling", - "description_en": "Cross association", - }, - headers=auth_headers(admin_token), + second_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="AI", title_en="AI"), ) - assert specialisation_response.status_code in (200, 201), specialisation_response.text - specialisation_id = specialisation_response.json()["specialisation_id"] + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] - specialisation_detail = client.get(f"/specialisations/{specialisation_id}") - assert specialisation_detail.status_code == 200, specialisation_detail.text - assert {program["program_id"] for program in specialisation_detail.json()["programs"]} == { - base_program["program_id"], - second_program_id, - } + program_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Industriell ekonomi", + title_en="Industrial engineering", + specialisation_ids=[first_specialisation_id, second_specialisation_id], + ), + ) + assert program_response.status_code in (200, 201), program_response.text + program_id = program_response.json()["program_id"] + + first_specialisation_detail = client.get(f"/specialisations/{first_specialisation_id}") + assert first_specialisation_detail.status_code == 200, first_specialisation_detail.text + + second_specialisation_detail = client.get(f"/specialisations/{second_specialisation_id}") + assert second_specialisation_detail.status_code == 200, second_specialisation_detail.text db_session.expire_all() - first_program_detail = client.get(f"/programs/{base_program['program_id']}") - assert first_program_detail.status_code == 200, first_program_detail.text - assert specialisation_id in { - specialisation["specialisation_id"] for specialisation in first_program_detail.json()["specialisations"] + program_detail = client.get(f"/programs/{program_id}") + assert program_detail.status_code == 200, program_detail.text + assert {specialisation["specialisation_id"] for specialisation in program_detail.json()["specialisations"]} == { + first_specialisation_id, + second_specialisation_id, } - second_program_detail = client.get(f"/programs/{second_program_id}") - assert second_program_detail.status_code == 200, second_program_detail.text - assert specialisation_id in { - specialisation["specialisation_id"] for specialisation in second_program_detail.json()["specialisations"] - } +def test_create_program_rolls_back_when_any_specialisation_id_is_missing(client, admin_token): + first_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Kemiteknik", title_en="Chemical engineering"), + ) + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] -def test_create_specialisation_rolls_back_when_any_program_id_is_missing(client, admin_token, base_program): - second_program_response = create_program( + second_specialisation_response = create_specialisation( client, token=admin_token, - **program_data_factory(title_sv="Kemiteknik", title_en="Chemical engineering"), + **specialisation_data_factory(title_sv="Bioteknik", title_en="Biotechnology"), ) - assert second_program_response.status_code in (200, 201), second_program_response.text - second_program_id = second_program_response.json()["program_id"] + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] - payload = specialisation_data_factory( - title_sv="Rollback specialisation", - title_en="Rollback specialisation", - program_ids=[base_program["program_id"], second_program_id, 999999], + payload = program_data_factory( + title_sv="Rollback program", + title_en="Rollback program", + specialisation_ids=[first_specialisation_id, second_specialisation_id, 999999], description_sv="Should not persist", description_en="Should not persist", ) - before_list = client.get("/specialisations/") + before_list = client.get("/programs/") assert before_list.status_code == 200, before_list.text - before_specialisations = before_list.json() - before_count = len(before_specialisations) + before_programs = before_list.json() + before_count = len(before_programs) - create_response = create_specialisation(client, token=admin_token, **payload) - assert create_response.status_code in (400, 404) + create_response = create_program(client, token=admin_token, **payload) + assert create_response.status_code == 404 - after_list = client.get("/specialisations/") + after_list = client.get("/programs/") assert after_list.status_code == 200, after_list.text - after_specialisations = after_list.json() + after_programs = after_list.json() - assert len(after_specialisations) == before_count - assert all(specialisation["title_en"] != payload["title_en"] for specialisation in after_specialisations) + assert len(after_programs) == before_count + assert all(program["title_en"] != payload["title_en"] for program in after_programs) -def test_update_specialisation_rolls_back_when_any_program_id_is_missing(client, admin_token, base_program): - second_program_response = create_program( +def test_update_program_rolls_back_when_any_specialisation_id_is_missing(client, admin_token): + first_specialisation_response = create_specialisation( client, token=admin_token, - **program_data_factory(title_sv="Farkostteknik", title_en="Vehicle engineering"), + **specialisation_data_factory(title_sv="Signalbehandling", title_en="Signal processing"), ) - assert second_program_response.status_code in (200, 201), second_program_response.text - second_program_id = second_program_response.json()["program_id"] + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] - create_response = create_specialisation( + second_specialisation_response = create_specialisation( client, token=admin_token, **specialisation_data_factory( - title_sv="Signalbehandling", - title_en="Signal processing", - program_ids=[base_program["program_id"], second_program_id], + title_sv="Reglersystem", + title_en="Control systems", + ), + ) + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] + + create_response = create_program( + client, + token=admin_token, + **program_data_factory( + title_sv="Farkostteknik", + title_en="Vehicle engineering", + specialisation_ids=[first_specialisation_id, second_specialisation_id], ), ) assert create_response.status_code in (200, 201), create_response.text - created_specialisation = create_response.json() - specialisation_id = created_specialisation["specialisation_id"] + created_program = create_response.json() + program_id = created_program["program_id"] - update_payload = specialisation_data_factory( - title_sv="Signalbehandling uppdaterad", - title_en="Signal processing updated", - program_ids=[second_program_id, 999999], + update_payload = program_data_factory( + title_sv="Farkostteknik uppdaterad", + title_en="Vehicle engineering updated", + specialisation_ids=[second_specialisation_id, 999999], description_sv="Should not persist update", description_en="Should not persist update", ) update_response = client.patch( - f"/specialisations/{specialisation_id}", + f"/programs/{program_id}", json=update_payload, headers=auth_headers(admin_token), ) - assert update_response.status_code in (400, 404) + assert update_response.status_code == 404 - detail_response = client.get(f"/specialisations/{specialisation_id}") + detail_response = client.get(f"/programs/{program_id}") assert detail_response.status_code == 200, detail_response.text detail_data = detail_response.json() - assert detail_data["title_en"] == created_specialisation["title_en"] - assert {program["program_id"] for program in detail_data["programs"]} == { - base_program["program_id"], - second_program_id, + assert detail_data["title_en"] == created_program["title_en"] + assert {specialisation["specialisation_id"] for specialisation in detail_data["specialisations"]} == { + first_specialisation_id, + second_specialisation_id, } @pytest.mark.parametrize("token_fixture, expected_status", [("member_token", 403), (None, 401)]) -def test_create_specialisation_requires_permission(client, request, token_fixture, expected_status, base_program): +def test_create_specialisation_requires_permission(client, request, token_fixture, expected_status): token = request.getfixturevalue(token_fixture) if token_fixture else None - response = create_specialisation(client, token=token, program_ids=[base_program["program_id"]]) + response = create_specialisation(client, token=token) assert response.status_code == expected_status -def test_create_course_with_relationships_success(client, admin_token, plugg_relationship_ids): +def test_create_course_success(client, admin_token): payload = course_data_factory( title="Diskret matematik", course_code="FMAB10", description="Relations and graphs", - program_year_ids=[plugg_relationship_ids["program_year_id"]], - specialisation_ids=[plugg_relationship_ids["specialisation_id"]], ) response = create_course(client, token=admin_token, **payload) @@ -462,124 +700,373 @@ def test_create_course_with_relationships_success(client, admin_token, plugg_rel data = response.json() assert data["title"] == "Diskret matematik" assert data["course_code"] == "FMAB10" - assert {year["program_year_id"] for year in data["program_years"]} == {plugg_relationship_ids["program_year_id"]} - assert {spec["specialisation_id"] for spec in data["specialisations"]} == { - plugg_relationship_ids["specialisation_id"] - } + assert data["program_years"] == [] + assert data["specialisations"] == [] -def test_create_course_with_missing_relationships_returns_404(client, admin_token): - payload = course_data_factory( - title="Ogiltig kurs", - course_code="FMAB11", - program_year_ids=[123456], - specialisation_ids=[654321], +def test_course_get_by_url_title_success(client, admin_token): + create_response = create_course( + client, + token=admin_token, + **course_data_factory(title="Reglerteori"), ) + assert create_response.status_code in (200, 201), create_response.text + course_id = create_response.json()["course_id"] - response = create_course(client, token=admin_token, **payload) + by_url_response = client.get("/courses/by_url_title/reglerteori") + assert by_url_response.status_code == 200, by_url_response.text + assert by_url_response.json()["course_id"] == course_id + + +def test_create_course_with_duplicate_urlized_title_returns_409(client, admin_token): + first_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Reglerteori", + course_code="FRTN05", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Reglerteöri!!!", + course_code="FRTN06", + ), + ) + assert second_response.status_code == 409 + + +def test_update_course_with_duplicate_urlized_title_returns_409(client, admin_token): + first_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Mekanik", + course_code="FMEA10", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Termodynamik", + course_code="FMEA11", + ), + ) + assert second_response.status_code in (200, 201), second_response.text + + second_course_id = second_response.json()["course_id"] + update_response = client.patch( + f"/courses/{second_course_id}", + json=course_data_factory( + title=" mekaNIK ", + course_code="FMEA11", + ), + headers=auth_headers(admin_token), + ) + assert update_response.status_code == 409 + + +def test_create_program_year_requires_existing_course(client, admin_token, base_program): + response = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=base_program["program_id"], course_ids=[999999]), + ) assert response.status_code == 404 -@pytest.mark.parametrize("invalid_field", ["program_year_ids", "specialisation_ids"]) -def test_create_course_rolls_back_when_any_relationship_id_is_missing( +def test_create_program_year_with_course_relationships_success( client, admin_token, - plugg_relationship_ids, - invalid_field, + base_program, ): - valid_program_year_id = plugg_relationship_ids["program_year_id"] - valid_specialisation_id = plugg_relationship_ids["specialisation_id"] + course_response = create_course( + client, + token=admin_token, + **course_data_factory(title="Rollteori", course_code="FMAB11"), + ) + assert course_response.status_code in (200, 201), course_response.text + course_id = course_response.json()["course_id"] - rollback_course_codes = { - "program_year_ids": "RBKPY001", - "specialisation_ids": "RBKSP001", - } + program_year_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory(program_id=base_program["program_id"], course_ids=[course_id]), + ) + assert program_year_response.status_code in (200, 201), program_year_response.text - payload = course_data_factory( - title="Rollback course", - course_code=rollback_course_codes[invalid_field], - description="Should not persist on failed relationship validation", - program_year_ids=[valid_program_year_id], - specialisation_ids=[valid_specialisation_id], + data = program_year_response.json() + assert data["program_id"] == base_program["program_id"] + assert {course["course_id"] for course in data["courses"]} == {course_id} + + +def test_create_program_year_rolls_back_when_any_course_id_is_missing(client, admin_token, base_program): + course_response = create_course( + client, + token=admin_token, + **course_data_factory(title="Rollback course", course_code="RBKPY001"), + ) + assert course_response.status_code in (200, 201), course_response.text + course_id = course_response.json()["course_id"] + + payload = program_year_data_factory( + program_id=base_program["program_id"], + title_sv="Rollback årskurs", + title_en="Rollback year", + course_ids=[course_id, 999999], ) - payload[invalid_field] = [payload[invalid_field][0], 999999] - before_list = client.get("/courses/") + before_list = client.get("/program-years/") assert before_list.status_code == 200, before_list.text before_count = len(before_list.json()) - response = create_course(client, token=admin_token, **payload) + response = create_program_year(client, token=admin_token, **payload) assert response.status_code == 404 - after_list = client.get("/courses/") + after_list = client.get("/program-years/") assert after_list.status_code == 200, after_list.text - after_courses = after_list.json() + after_program_years = after_list.json() - assert len(after_courses) == before_count - assert all(course["course_code"] != payload["course_code"] for course in after_courses) + assert len(after_program_years) == before_count + assert all(program_year["title_en"] != payload["title_en"] for program_year in after_program_years) -def test_update_course_relationships_success(client, admin_token, plugg_relationship_ids): - program_id = plugg_relationship_ids["program_id"] +def test_update_program_year_course_associations_success(client, admin_token, base_program): + first_course_response = create_course( + client, + token=admin_token, + **course_data_factory(title="Flervariabelanalys", course_code="FMAB12"), + ) + assert first_course_response.status_code in (200, 201), first_course_response.text + first_course_id = first_course_response.json()["course_id"] - year_resp = create_program_year( + second_course_response = create_course( client, token=admin_token, - **program_year_data_factory(program_id=program_id, title_sv="Arskurs 3", title_en="Year 3"), + **course_data_factory(title="Flervariabelanalys forts", course_code="FMAB13"), ) - assert year_resp.status_code in (200, 201), year_resp.text - new_program_year_id = year_resp.json()["program_year_id"] + assert second_course_response.status_code in (200, 201), second_course_response.text + second_course_id = second_course_response.json()["course_id"] - specialisation_resp = create_specialisation( + create_program_year_response = create_program_year( client, token=admin_token, - **specialisation_data_factory(program_ids=[program_id], title_sv="Datavetenskap", title_en="Computer science"), + **program_year_data_factory( + program_id=base_program["program_id"], + title_sv="Årskurs 4", + title_en="Year 4", + course_ids=[first_course_id], + ), ) - assert specialisation_resp.status_code in (200, 201), specialisation_resp.text - new_specialisation_id = specialisation_resp.json()["specialisation_id"] + assert create_program_year_response.status_code in (200, 201), create_program_year_response.text + program_year_id = create_program_year_response.json()["program_year_id"] - create_response = create_course( + update_response = client.patch( + f"/program-years/{program_year_id}", + json=program_year_data_factory( + program_id=base_program["program_id"], + title_sv="Årskurs 4 uppdaterad", + title_en="Year 4 updated", + course_ids=[second_course_id], + ), + headers=auth_headers(admin_token), + ) + assert update_response.status_code == 200, update_response.text + + updated = update_response.json() + assert updated["title_en"] == "Year 4 updated" + assert {course["course_id"] for course in updated["courses"]} == {second_course_id} + + +def test_create_specialisation_with_course_relationships_success(client, admin_token): + course_response = create_course( client, token=admin_token, **course_data_factory( - title="Flervariabelanalys", - course_code="FMAB12", - program_year_ids=[plugg_relationship_ids["program_year_id"]], - specialisation_ids=[plugg_relationship_ids["specialisation_id"]], + title="Sannolikhetsteori", + course_code="FMSF70", + ), + ) + assert course_response.status_code in (200, 201), course_response.text + course_id = course_response.json()["course_id"] + + specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory( + title_sv="Sannolikhet och statistik", + title_en="Probability and statistics", + course_ids=[course_id], + ), + ) + assert specialisation_response.status_code in (200, 201), specialisation_response.text + data = specialisation_response.json() + assert {course["course_id"] for course in data["courses"]} == {course_id} + + +def test_create_specialisation_requires_existing_course(client, admin_token): + response = create_specialisation(client, token=admin_token, course_ids=[999999]) + assert response.status_code == 404 + + +def test_update_specialisation_course_associations_success(client, admin_token): + first_course_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Kommunikation", + course_code="EITA15", + ), + ) + assert first_course_response.status_code in (200, 201), first_course_response.text + first_course_id = first_course_response.json()["course_id"] + + second_course_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Reglerteknik", + course_code="FRTN01", + ), + ) + assert second_course_response.status_code in (200, 201), second_course_response.text + second_course_id = second_course_response.json()["course_id"] + + create_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory( + title_sv="AI och data", + title_en="AI and data", + course_ids=[first_course_id], ), ) assert create_response.status_code in (200, 201), create_response.text - course_id = create_response.json()["course_id"] + specialisation_id = create_response.json()["specialisation_id"] + + update_response = client.patch( + f"/specialisations/{specialisation_id}", + json={ + "title_sv": "AI och data uppdaterad", + "title_en": "AI and data updated", + "course_ids": [second_course_id], + "description_sv": "Updated association", + "description_en": "Updated association", + }, + headers=auth_headers(admin_token), + ) + + assert update_response.status_code == 200, update_response.text + updated = update_response.json() + assert updated["title_en"] == "AI and data updated" + assert {course["course_id"] for course in updated["courses"]} == {second_course_id} + + +def test_create_specialisation_rolls_back_when_any_course_id_is_missing(client, admin_token): + course_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Signaler", + course_code="EITA20", + ), + ) + assert course_response.status_code in (200, 201), course_response.text + valid_course_id = course_response.json()["course_id"] + + payload = specialisation_data_factory( + title_sv="Rollback specialisering", + title_en="Rollback specialisation", + course_ids=[valid_course_id, 999999], + ) + + before_list = client.get("/specialisations/") + assert before_list.status_code == 200, before_list.text + before_count = len(before_list.json()) + + create_response = create_specialisation(client, token=admin_token, **payload) + assert create_response.status_code == 404 + + after_list = client.get("/specialisations/") + assert after_list.status_code == 200, after_list.text + after_specialisations = after_list.json() + + assert len(after_specialisations) == before_count + assert all(specialisation["title_en"] != payload["title_en"] for specialisation in after_specialisations) + + +def test_update_specialisation_rolls_back_when_any_course_id_is_missing(client, admin_token): + first_course_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Nätverk", + course_code="EITF65", + ), + ) + assert first_course_response.status_code in (200, 201), first_course_response.text + first_course_id = first_course_response.json()["course_id"] + + second_course_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Distribuerade system", + course_code="EDA040", + ), + ) + assert second_course_response.status_code in (200, 201), second_course_response.text + second_course_id = second_course_response.json()["course_id"] + + create_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory( + title_sv="Signalbehandling", + title_en="Signal processing", + course_ids=[first_course_id], + ), + ) + assert create_response.status_code in (200, 201), create_response.text + created_specialisation = create_response.json() + specialisation_id = created_specialisation["specialisation_id"] - update_payload = course_data_factory( - title="Flervariabelanalys forts", - course_code="FMAB12", - description="Updated course", - program_year_ids=[new_program_year_id], - specialisation_ids=[new_specialisation_id], + update_payload = specialisation_data_factory( + title_sv="Signalbehandling uppdaterad", + title_en="Signal processing updated", + course_ids=[second_course_id, 999999], + description_sv="Should not persist update", + description_en="Should not persist update", ) update_response = client.patch( - f"/courses/{course_id}", + f"/specialisations/{specialisation_id}", json=update_payload, headers=auth_headers(admin_token), ) + assert update_response.status_code == 404 - assert update_response.status_code == 200 - data = update_response.json() - assert data["title"] == "Flervariabelanalys forts" - assert {year["program_year_id"] for year in data["program_years"]} == {new_program_year_id} - assert {spec["specialisation_id"] for spec in data["specialisations"]} == {new_specialisation_id} + detail_response = client.get(f"/specialisations/{specialisation_id}") + assert detail_response.status_code == 200, detail_response.text + detail_data = detail_response.json() + + assert detail_data["title_en"] == created_specialisation["title_en"] + assert {course["course_id"] for course in detail_data["courses"]} == {first_course_id} -def test_course_read_endpoints_are_public(client, admin_token, plugg_relationship_ids): +def test_course_read_endpoints_are_public(client, admin_token): create_response = create_course( client, token=admin_token, **course_data_factory( title="Signaler och system", course_code="ETIA20", - program_year_ids=[plugg_relationship_ids["program_year_id"]], - specialisation_ids=[plugg_relationship_ids["specialisation_id"]], ), ) assert create_response.status_code in (200, 201), create_response.text @@ -600,7 +1087,6 @@ def test_create_course_requires_permission( request, token_fixture, expected_status, - plugg_relationship_ids, ): token = request.getfixturevalue(token_fixture) if token_fixture else None @@ -610,22 +1096,18 @@ def test_create_course_requires_permission( **course_data_factory( title="Operativsystem", course_code="EDA092", - program_year_ids=[plugg_relationship_ids["program_year_id"]], - specialisation_ids=[plugg_relationship_ids["specialisation_id"]], ), ) assert response.status_code == expected_status -def test_delete_course_success(client, admin_token, plugg_relationship_ids): +def test_delete_course_success(client, admin_token): create_response = create_course( client, token=admin_token, **course_data_factory( title="Datastrukturer", course_code="EDA123", - program_year_ids=[plugg_relationship_ids["program_year_id"]], - specialisation_ids=[plugg_relationship_ids["specialisation_id"]], ), ) assert create_response.status_code in (200, 201), create_response.text From b4d1f6b6a1ee9f95779da665af9940d09cd159aa Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Fri, 17 Apr 2026 14:48:19 +0000 Subject: [PATCH 18/26] Add back program_id to simpleprogramyearread --- api_schemas/program_year_schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index 1037c6c..ef5e542 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -11,6 +11,7 @@ class SimpleProgramYearRead(BaseSchema): program_year_id: int title_sv: str title_en: str + program_id: int class ProgramYearRead(BaseSchema): From 1ada25ff9793468050e0ec54e8d580f308a5e21a Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sat, 18 Apr 2026 19:03:18 +0000 Subject: [PATCH 19/26] Changes to types and required fields based on frontend realities, --- api_schemas/course_document_schema.py | 4 ---- api_schemas/program_schema.py | 2 +- api_schemas/program_year_schema.py | 4 ++-- api_schemas/specialisation_schema.py | 4 ++-- routes/program_year_router.py | 9 +++++++++ routes/specialisation_router.py | 16 ++++++++++++++++ 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/api_schemas/course_document_schema.py b/api_schemas/course_document_schema.py index 6fb0f5f..7442996 100644 --- a/api_schemas/course_document_schema.py +++ b/api_schemas/course_document_schema.py @@ -20,7 +20,6 @@ class CourseDocumentRead(BaseSchema): class CourseDocumentCreate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] - file_name: Annotated[str, StringConstraints(max_length=MAX_DOC_FILE_NAME)] course_id: int author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] category: COURSE_DOCUMENT_CATEGORIES = "Other" @@ -30,7 +29,6 @@ class CourseDocumentCreate(BaseSchema): # Apparently I have to do this to be able to send JSON forms at the same time as files in the POST course document router def course_document_create_form( title: str = Form(...), - file_name: str = Form(...), course_id: int = Form(...), author: str = Form(...), category: COURSE_DOCUMENT_CATEGORIES = Form("Other"), @@ -38,7 +36,6 @@ def course_document_create_form( ) -> CourseDocumentCreate: return CourseDocumentCreate( title=title, - file_name=file_name, course_id=course_id, author=author, category=category, @@ -48,7 +45,6 @@ def course_document_create_form( class CourseDocumentUpdate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] - file_name: Annotated[str, StringConstraints(max_length=MAX_DOC_FILE_NAME)] author: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_AUTHOR)] category: COURSE_DOCUMENT_CATEGORIES = "Other" sub_category: Annotated[str, StringConstraints(max_length=MAX_COURSE_DOC_SUB_CATEGORY)] | None = None diff --git a/api_schemas/program_schema.py b/api_schemas/program_schema.py index 47095eb..87bb712 100644 --- a/api_schemas/program_schema.py +++ b/api_schemas/program_schema.py @@ -4,7 +4,7 @@ from helpers.constants import MAX_PROGRAM_DESC, MAX_PROGRAM_TITLE from api_schemas.program_year_schema import ProgramYearRead from api_schemas.course_schema import ( - SimpleCourseRead, # type: ignore + CourseRead, # type: ignore ) # Needed for pydantic forward references in ProgramYearRead and SpecialisationRead from api_schemas.specialisation_schema import ( SpecialisationRead, diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py index ef5e542..00a6153 100644 --- a/api_schemas/program_year_schema.py +++ b/api_schemas/program_year_schema.py @@ -4,7 +4,7 @@ from helpers.constants import MAX_PROGRAM_YEAR_DESC, MAX_PROGRAM_YEAR_TITLE if TYPE_CHECKING: - from api_schemas.course_schema import SimpleCourseRead + from api_schemas.course_schema import CourseRead class SimpleProgramYearRead(BaseSchema): @@ -22,7 +22,7 @@ class ProgramYearRead(BaseSchema): description_sv: str | None description_en: str | None associated_img_id: int | None - courses: list["SimpleCourseRead"] = [] + courses: list["CourseRead"] = [] class ProgramYearCreate(BaseSchema): diff --git a/api_schemas/specialisation_schema.py b/api_schemas/specialisation_schema.py index 2d95c3f..f2c061e 100644 --- a/api_schemas/specialisation_schema.py +++ b/api_schemas/specialisation_schema.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from api_schemas.program_schema import SimpleProgramRead - from api_schemas.course_schema import SimpleCourseRead + from api_schemas.course_schema import CourseRead class SimpleSpecialisationRead(BaseSchema): @@ -22,7 +22,7 @@ class SpecialisationRead(BaseSchema): description_sv: str | None description_en: str | None associated_img_id: int | None - courses: list["SimpleCourseRead"] = [] + courses: list["CourseRead"] = [] class SpecialisationCreate(BaseSchema): diff --git a/routes/program_year_router.py b/routes/program_year_router.py index 0f63a3c..b8146f5 100644 --- a/routes/program_year_router.py +++ b/routes/program_year_router.py @@ -18,6 +18,15 @@ def get_all_program_years(db: DB_dependency): return db.query(ProgramYear_DB).all() +@program_year_router.get("/by_program/{program_id}", response_model=list[ProgramYearRead]) +def get_program_years_by_program(program_id: int, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Program not found") + + return db.query(ProgramYear_DB).filter_by(program_id=program_id).all() + + @program_year_router.get("/by_url_title/{program_title}/{program_year_title}", response_model=ProgramYearRead) def get_program_year_by_url_title(program_title: str, program_year_title: str, db: DB_dependency): normalized_program_title = url_formatter(program_title) diff --git a/routes/specialisation_router.py b/routes/specialisation_router.py index ba55ed0..c1bc7a1 100644 --- a/routes/specialisation_router.py +++ b/routes/specialisation_router.py @@ -8,9 +8,11 @@ ) from database import DB_dependency from db_models.specialisation_model import Specialisation_DB +from db_models.program_specialisation_model import ProgramSpecialisation_DB from user.permission import Permission from helpers.url_formatter import url_formatter from services.specialisation_service import update_specialisation_course_associations, validate_course_ids +from db_models.program_model import Program_DB specialisation_router = APIRouter() @@ -21,6 +23,20 @@ def get_all_specialisations(db: DB_dependency): return db.query(Specialisation_DB).all() +@specialisation_router.get("/by_program/{program_id}", response_model=list[SpecialisationRead]) +def get_specialisations_by_program(program_id: int, db: DB_dependency): + program = db.query(Program_DB).filter_by(program_id=program_id).one_or_none() + if program is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Program not found") + + return ( + db.query(Specialisation_DB) + .join(Specialisation_DB.program_specialisations) + .filter(ProgramSpecialisation_DB.program_id == program_id) + .all() + ) + + @specialisation_router.get("/by_url_title/{title}", response_model=SpecialisationRead) def get_specialisation_by_url_title(title: str, db: DB_dependency): normalized_title = url_formatter(title) From 300ba3b6ea975d19299ca1c6bfcad9f6b9da8dd5 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sat, 18 Apr 2026 19:04:47 +0000 Subject: [PATCH 20/26] Fix bugs: file writes persisting after db problem or db object persisting after file write problem. Allow None type duplicate course codes. --- routes/course_document_router.py | 12 ++++++++- routes/course_router.py | 45 ++++++++++++++++++++++++++------ routes/document_router.py | 13 ++++++++- tests/test_course_documents.py | 33 +++++++++++++++++++++++ tests/test_documents.py | 25 ++++++++++++++++++ tests/test_plugg_resources.py | 22 ++++++++++++++++ 6 files changed, 140 insertions(+), 10 deletions(-) diff --git a/routes/course_document_router.py b/routes/course_document_router.py index 6240e36..c9790fd 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -63,12 +63,22 @@ async def create_course_document( try: db.add(course_document) - db.commit() + db.flush() file_path.write_bytes(file.file.read()) + db.commit() db.refresh(course_document) except IntegrityError: + # Something went wrong with the DB db.rollback() + if file_path.exists(): + file_path.unlink(missing_ok=True) raise HTTPException(400, detail="Something is invalid") + except OSError: + # Something went wrong with the file writing + db.rollback() + if file_path.exists(): + file_path.unlink(missing_ok=True) + raise HTTPException(500, detail="Could not save document file") return course_document diff --git a/routes/course_router.py b/routes/course_router.py index 0277bd1..7acca43 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -5,6 +5,10 @@ from db_models.course_model import Course_DB from user.permission import Permission from helpers.url_formatter import url_formatter +from db_models.program_year_model import ProgramYear_DB +from db_models.specialisation_course_model import SpecialisationCourse_DB +from db_models.specialisation_model import Specialisation_DB +from db_models.program_year_course_model import ProgramYearCourse_DB course_router = APIRouter() @@ -15,6 +19,34 @@ def get_all_courses(db: DB_dependency): return db.query(Course_DB).all() +@course_router.get("/by_program_year/{program_year_id}", response_model=list[CourseRead]) +def get_courses_by_program_year(program_year_id: int, db: DB_dependency): + program_year = db.query(ProgramYear_DB).filter_by(program_year_id=program_year_id).one_or_none() + if program_year is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Program year not found") + + return ( + db.query(Course_DB) + .join(Course_DB.program_year_courses) + .filter(ProgramYearCourse_DB.program_year_id == program_year_id) + .all() + ) + + +@course_router.get("/by_specialisation/{specialisation_id}", response_model=list[CourseRead]) +def get_courses_by_specialisation(specialisation_id: int, db: DB_dependency): + specialisation = db.query(Specialisation_DB).filter_by(specialisation_id=specialisation_id).one_or_none() + if specialisation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Specialisation not found") + + return ( + db.query(Course_DB) + .join(Course_DB.specialisation_courses) + .filter(SpecialisationCourse_DB.specialisation_id == specialisation_id) + .all() + ) + + @course_router.get("/by_url_title/{title}", response_model=CourseRead) def get_course_by_url_title(title: str, db: DB_dependency): normalized_title = url_formatter(title) @@ -40,13 +72,13 @@ def create_course(data: CourseCreate, db: DB_dependency): # Check for duplicate course title using url-formatter, so title uniqueness # matches URL lookup conventions used elsewhere in plugg routes. - existing_title = db.query(Course_DB).filter_by(title_urlized=normalized_title).one_or_none() + existing_title = db.query(Course_DB).filter_by(title_urlized=normalized_title).first() if existing_title is not None: raise HTTPException(status.HTTP_409_CONFLICT, detail="Course title already exists") # Check for duplicate course code, since we require course codes to be unique. - existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).one_or_none() - if existing_course is not None: + existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).first() + if existing_course is not None and data.course_code is not None: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=f"Course with course_code {data.course_code} already exists, cannot create course", @@ -76,17 +108,14 @@ def update_course(course_id: int, data: CourseUpdate, db: DB_dependency): # Check for duplicate course title using url-formatter. Ignore the current course when checking. existing_title = ( - db.query(Course_DB) - .filter(Course_DB.course_id != course_id) - .filter_by(title_urlized=normalized_title) - .one_or_none() + db.query(Course_DB).filter(Course_DB.course_id != course_id).filter_by(title_urlized=normalized_title).first() ) if existing_title is not None: raise HTTPException(status.HTTP_409_CONFLICT, detail="Course title already exists") # Check for duplicate course code, since we require course codes to be unique. if data.course_code and data.course_code != course.course_code: - existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).one_or_none() + existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).first() if existing_course is not None: raise HTTPException( status.HTTP_400_BAD_REQUEST, diff --git a/routes/document_router.py b/routes/document_router.py index fb32104..d4420ae 100644 --- a/routes/document_router.py +++ b/routes/document_router.py @@ -49,11 +49,22 @@ async def upload_document( try: db.add(document) - db.commit() + db.flush() file_path.write_bytes(file.file.read()) + db.commit() + db.refresh(document) except IntegrityError: + # Something went wrong with the DB db.rollback() + if file_path.exists(): + file_path.unlink(missing_ok=True) raise HTTPException(400, detail="Something is invalid") + except OSError: + # Something went wrong writing the file + db.rollback() + if file_path.exists(): + file_path.unlink(missing_ok=True) + raise HTTPException(500, detail="Could not save document file") return document diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index 14c912e..ee33709 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -1,6 +1,7 @@ # type: ignore import os import pytest +from pathlib import Path from .basic_factories import ( auth_headers, @@ -44,6 +45,38 @@ def test_create_course_document_success(client, admin_token, plugg_course_id, ex assert data["file_name"].endswith(".pdf") +def test_create_course_document_does_not_persist_if_file_write_fails( + client, + admin_token, + plugg_course_id, + example_file, + monkeypatch, + db_session, +): + from db_models.course_document_model import CourseDocument_DB + + def _raise_oserror(self, data): + raise OSError("Simulated file write failure") + + monkeypatch.setattr(Path, "write_bytes", _raise_oserror) + + payload = course_document_data_factory(course_id=plugg_course_id) + response = create_course_document( + client, + token=admin_token, + file=example_file, + **payload, + ) + + assert response.status_code == 500 + assert response.json()["detail"] == "Could not save document file" + + persisted = ( + db_session.query(CourseDocument_DB).filter_by(title=payload["title"], course_id=plugg_course_id).one_or_none() + ) + assert persisted is None + + def test_patch_course_document_success(client, admin_token, plugg_course_id, example_file): create_response = create_course_document( client, diff --git a/tests/test_documents.py b/tests/test_documents.py index 87b06b9..edeaa17 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -1,6 +1,7 @@ # type: ignore from .basic_factories import auth_headers import pytest +from pathlib import Path def create_document_object( @@ -55,6 +56,30 @@ def test_create_document(client, admin_token, example_file): assert response.json()["is_private"] is False +def test_create_document_does_not_persist_if_file_write_fails( + client, admin_token, example_file, monkeypatch, db_session +): + from db_models.document_model import Document_DB + + def _raise_oserror(self, data): + raise OSError("Simulated file write failure") + + monkeypatch.setattr(Path, "write_bytes", _raise_oserror) + + response = create_document_object( + client, + admin_token, + example_file, + title="Write fail doc", + ) + + assert response.status_code == 500 + assert response.json()["detail"] == "Could not save document file" + + persisted = db_session.query(Document_DB).filter_by(title="Write fail doc").one_or_none() + assert persisted is None + + def test_patch_document(client, admin_token, example_file): """Test that an admin can update a document""" # First, create a document to update diff --git a/tests/test_plugg_resources.py b/tests/test_plugg_resources.py index 47b228a..bbbd7b8 100644 --- a/tests/test_plugg_resources.py +++ b/tests/test_plugg_resources.py @@ -740,6 +740,28 @@ def test_create_course_with_duplicate_urlized_title_returns_409(client, admin_to assert second_response.status_code == 409 +def test_create_course_with_duplicate_course_code_fails(client, admin_token): + first_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Reglerteori grund", + course_code="FRTN07", + ), + ) + assert first_response.status_code in (200, 201), first_response.text + + second_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Reglerteori forts", + course_code="FRTN07", + ), + ) + assert second_response.status_code in (400, 409) + + def test_update_course_with_duplicate_urlized_title_returns_409(client, admin_token): first_response = create_course( client, From c7fb3372d054bc90eca24297de6a8707ce2e5a73 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Thu, 23 Apr 2026 12:13:19 +0000 Subject: [PATCH 21/26] Add more edge case tests. Fix removal of associated_img files when parents are deleted. Better test separation --- routes/course_router.py | 27 ++++++++++- routes/program_router.py | 23 ++++++++- routes/program_year_router.py | 15 +++++- routes/specialisation_router.py | 15 +++++- services/plugg_cleanup_service.py | 77 ++++++++++++++++++++++++++++++ tests/conftest.py | 6 ++- tests/test_associated_img.py | 59 +++++++++++++++++++++++ tests/test_course_documents.py | 2 - tests/test_plugg_resources.py | 79 +++++++++++++++++++++++++++++++ 9 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 services/plugg_cleanup_service.py diff --git a/routes/course_router.py b/routes/course_router.py index 7acca43..71d5a54 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -1,4 +1,7 @@ +import os + from fastapi import APIRouter, HTTPException, status +from sqlalchemy.exc import IntegrityError from api_schemas.course_schema import CourseCreate, CourseRead, CourseUpdate from database import DB_dependency @@ -9,6 +12,11 @@ from db_models.specialisation_course_model import SpecialisationCourse_DB from db_models.specialisation_model import Specialisation_DB from db_models.program_year_course_model import ProgramYearCourse_DB +from services.plugg_cleanup_service import ( + collect_course_document_paths_and_delete_rows, + collect_orphaned_associated_img_path_after_detach, + remove_files, +) course_router = APIRouter() @@ -139,6 +147,21 @@ def delete_course(course_id: int, db: DB_dependency): if course is None: raise HTTPException(status.HTTP_404_NOT_FOUND) - db.delete(course) - db.commit() + document_base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + if document_base_path is None: + raise HTTPException(500, detail="Document base path is not configured") + + files_to_remove: list[str] = [] + + try: + files_to_remove.extend(collect_course_document_paths_and_delete_rows(db, course, document_base_path)) + files_to_remove.extend(collect_orphaned_associated_img_path_after_detach(db, course)) + + db.delete(course) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(500, detail="Could not delete course and all related resources") + + remove_files(files_to_remove) return course diff --git a/routes/program_router.py b/routes/program_router.py index 4505f42..92d636a 100644 --- a/routes/program_router.py +++ b/routes/program_router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, status from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError from api_schemas.program_schema import ProgramCreate, ProgramRead, ProgramUpdate from database import DB_dependency @@ -10,6 +11,7 @@ update_program_specialisation_associations, validate_specialisation_ids, ) +from services.plugg_cleanup_service import collect_orphaned_associated_img_path_after_detach, remove_files program_router = APIRouter() @@ -160,6 +162,23 @@ def delete_program(program_id: int, db: DB_dependency): if program is None: raise HTTPException(status.HTTP_404_NOT_FOUND) - db.delete(program) - db.commit() + # Fixing proper handling of program_years is too much work so let's just forbid deletion if + # there are program_years associated. + if program.program_years: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Cannot delete program with associated program years. Delete them first.", + ) + + files_to_remove: list[str] = [] + + try: + files_to_remove.extend(collect_orphaned_associated_img_path_after_detach(db, program)) + db.delete(program) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(500, detail="Could not delete program and all related resources") + + remove_files(files_to_remove) return program diff --git a/routes/program_year_router.py b/routes/program_year_router.py index b8146f5..676ce95 100644 --- a/routes/program_year_router.py +++ b/routes/program_year_router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, status from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError from api_schemas.program_year_schema import ProgramYearCreate, ProgramYearRead, ProgramYearUpdate from database import DB_dependency @@ -8,6 +9,7 @@ from user.permission import Permission from helpers.url_formatter import url_formatter from services.program_year_service import update_program_year_course_associations, validate_course_ids +from services.plugg_cleanup_service import collect_orphaned_associated_img_path_after_detach, remove_files program_year_router = APIRouter() @@ -196,6 +198,15 @@ def delete_program_year(program_year_id: int, db: DB_dependency): if program_year is None: raise HTTPException(status.HTTP_404_NOT_FOUND) - db.delete(program_year) - db.commit() + files_to_remove: list[str] = [] + + try: + files_to_remove.extend(collect_orphaned_associated_img_path_after_detach(db, program_year)) + db.delete(program_year) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(500, detail="Could not delete program year and all related resources") + + remove_files(files_to_remove) return program_year diff --git a/routes/specialisation_router.py b/routes/specialisation_router.py index c1bc7a1..5d63559 100644 --- a/routes/specialisation_router.py +++ b/routes/specialisation_router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, status from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError from api_schemas.specialisation_schema import ( SpecialisationCreate, @@ -13,6 +14,7 @@ from helpers.url_formatter import url_formatter from services.specialisation_service import update_specialisation_course_associations, validate_course_ids from db_models.program_model import Program_DB +from services.plugg_cleanup_service import collect_orphaned_associated_img_path_after_detach, remove_files specialisation_router = APIRouter() @@ -181,6 +183,15 @@ def delete_specialisation(specialisation_id: int, db: DB_dependency): if specialisation is None: raise HTTPException(status.HTTP_404_NOT_FOUND) - db.delete(specialisation) - db.commit() + files_to_remove: list[str] = [] + + try: + files_to_remove.extend(collect_orphaned_associated_img_path_after_detach(db, specialisation)) + db.delete(specialisation) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(500, detail="Could not delete specialisation and all related resources") + + remove_files(files_to_remove) return specialisation diff --git a/services/plugg_cleanup_service.py b/services/plugg_cleanup_service.py new file mode 100644 index 0000000..6b7c9e8 --- /dev/null +++ b/services/plugg_cleanup_service.py @@ -0,0 +1,77 @@ +from pathlib import Path + +from sqlalchemy.orm import Session + +from db_models.associated_img_model import AssociatedImg_DB +from db_models.course_model import Course_DB +from db_models.course_document_model import CourseDocument_DB +from db_models.program_model import Program_DB +from db_models.program_year_model import ProgramYear_DB +from db_models.specialisation_model import Specialisation_DB + +PluggEntityWithAssociatedImage = Program_DB | ProgramYear_DB | Course_DB | Specialisation_DB + + +def collect_course_document_paths_and_delete_rows( + db: Session, + course: Course_DB, + document_base_path: str, +) -> list[str]: + """Delete a course's document rows and return corresponding file paths for post-commit cleanup.""" + file_paths: list[str] = [] + + documents = db.query(CourseDocument_DB).filter_by(course_id=course.course_id).all() + for document in documents: + file_paths.append(str(Path(document_base_path) / document.file_name)) + db.delete(document) + + return file_paths + + +def collect_orphaned_associated_img_path_after_detach( + db: Session, + entity: PluggEntityWithAssociatedImage, +) -> list[str]: + """Detach entity from its associated image and queue image file deletion if image becomes orphaned.""" + associated_img_id = entity.associated_img_id + entity.associated_img = None + + # db_session in tests has autoflush disabled, so flush before reference checks. + db.flush() + + img_path = _delete_associated_img_if_orphaned(db, associated_img_id) + if img_path is None: + return [] + return [img_path] + + +def remove_files(paths: list[str]) -> None: + """Best-effort file cleanup after DB commits.""" + for path in paths: + if not path: + continue + try: + Path(path).unlink(missing_ok=True) + except OSError: + # Deletion has already happened in DB; do not crash on file-system cleanup failures. + continue + + +def _delete_associated_img_if_orphaned(db: Session, associated_img_id: int | None) -> str | None: + if associated_img_id is None: + return None + + img = db.query(AssociatedImg_DB).filter_by(associated_img_id=associated_img_id).one_or_none() + if img is None: + return None + + still_referenced = any( + db.query(model).filter(model.associated_img_id == associated_img_id).first() is not None + for model in (Program_DB, ProgramYear_DB, Course_DB, Specialisation_DB) + ) + + if still_referenced: + return None + + db.delete(img) + return img.path diff --git a/tests/conftest.py b/tests/conftest.py index 711ad40..d0a2e03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,11 @@ def client(db_session): """FastAPI test client with database dependency override.""" def get_test_db(): - yield db_session + try: + yield db_session + finally: + # Clear session state to prevent stale data issues between tests + db_session.expire_all() # Override dependency app.dependency_overrides[get_db] = get_test_db diff --git a/tests/test_associated_img.py b/tests/test_associated_img.py index 5fbeafc..4303ca8 100644 --- a/tests/test_associated_img.py +++ b/tests/test_associated_img.py @@ -1,6 +1,7 @@ # type: ignore import asyncio import os +import pytest from database import redis_client from db_models.associated_img_model import AssociatedImg_DB @@ -249,3 +250,61 @@ def test_deleting_associated_image_unlinks_all_association_types(client, admin_t assert delete_response.status_code == 200, delete_response.text assert _get_img_id_from_entity(client, association_type, association_id) is None + + +# Excluding program which has associated program years which makes orphaning logic more complex than I care to implement +@pytest.mark.parametrize( + "association_type", + ["program_year", "course", "specialisation"], +) +def test_deleting_plugg_entity_cleans_up_associated_image_everywhere( + client, + admin_token, + db_session, + example_image_file, + association_type, +): + ids = _create_plugg_entities_for_image_tests(client, admin_token) + association_id = ids[f"{association_type}_id"] + + upload_response = create_associated_image( + client, + token=admin_token, + association_type=association_type, + association_id=association_id, + file=example_image_file, + ) + assert upload_response.status_code in (200, 201), upload_response.text + + img_id = _get_img_id_from_entity(client, association_type, association_id) + assert img_id is not None + + image_in_db = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=img_id).one_or_none() + assert image_in_db is not None + assert os.path.exists(image_in_db.path) + + delete_path_by_type = { + "program": f"/programs/{association_id}", + "program_year": f"/program-years/{association_id}", + "course": f"/courses/{association_id}", + "specialisation": f"/specialisations/{association_id}", + } + + delete_response = client.delete(delete_path_by_type[association_type], headers=auth_headers(admin_token)) + assert delete_response.status_code == 200, delete_response.text + + deleted_image = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=img_id).one_or_none() + assert deleted_image is None + assert not os.path.exists(image_in_db.path) + + +def test_program_deletion_denied_if_program_years_exist(client, admin_token): + program_response = create_program(client, token=admin_token) + assert program_response.status_code in (200, 201), program_response.text + program_id = program_response.json()["program_id"] + + program_year_response = create_program_year(client, token=admin_token, program_id=program_id) + assert program_year_response.status_code in (200, 201), program_year_response.text + + delete_response = client.delete(f"/programs/{program_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 400 diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index ee33709..ce8bbdb 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -89,7 +89,6 @@ def test_patch_course_document_success(client, admin_token, plugg_course_id, exa patch_data = { "title": "Updated notes", - "file_name": "updated_notes.pdf", "author": "Updated author", "category": "Summary", "sub_category": "by author 2", @@ -104,7 +103,6 @@ def test_patch_course_document_success(client, admin_token, plugg_course_id, exa assert patch_response.status_code == 200, patch_response.text data = patch_response.json() assert data["title"] == "Updated notes" - assert data["file_name"] == "updated_notes.pdf" assert data["author"] == "Updated author" assert data["category"] == "Summary" diff --git a/tests/test_plugg_resources.py b/tests/test_plugg_resources.py index bbbd7b8..7a76149 100644 --- a/tests/test_plugg_resources.py +++ b/tests/test_plugg_resources.py @@ -1,10 +1,13 @@ # type: ignore +import os import pytest from .basic_factories import ( auth_headers, + create_associated_image, course_data_factory, create_course, + create_course_document, create_program, create_program_year, create_specialisation, @@ -1143,3 +1146,79 @@ def test_delete_course_success(client, admin_token): get_response = client.get(f"/courses/{course_id}") assert get_response.status_code == 404 + + +def test_delete_course_removes_associated_images_and_course_documents_from_db_and_filesystem( + client, + admin_token, + db_session, + example_file, + example_image_file, +): + from db_models.associated_img_model import AssociatedImg_DB + from db_models.course_document_model import CourseDocument_DB + + create_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Databasteknik", + course_code="EDA216", + ), + ) + assert create_response.status_code in (200, 201), create_response.text + course_id = create_response.json()["course_id"] + + upload_image_response = create_associated_image( + client, + token=admin_token, + association_type="course", + association_id=course_id, + file=example_image_file, + ) + assert upload_image_response.status_code in (200, 201), upload_image_response.text + + course_response = client.get(f"/courses/{course_id}") + assert course_response.status_code == 200, course_response.text + associated_img_id = course_response.json()["associated_img_id"] + assert associated_img_id is not None + + associated_img = db_session.query(AssociatedImg_DB).filter_by(associated_img_id=associated_img_id).one_or_none() + assert associated_img is not None + associated_img_path = associated_img.path + assert os.path.exists(associated_img_path) + + create_document_response = create_course_document( + client, + token=admin_token, + file=example_file, + course_id=course_id, + title="Föreläsningsanteckningar", + author="Ingen", + category="Notes", + sub_category="VT26", + ) + assert create_document_response.status_code in (200, 201), create_document_response.text + course_document_id = create_document_response.json()["course_document_id"] + + course_document = db_session.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() + assert course_document is not None + + document_base_path = os.environ["COURSE_DOCUMENT_BASE_PATH"] + course_document_path = os.path.join(document_base_path, course_document.file_name) + assert os.path.exists(course_document_path) + + delete_response = client.delete(f"/courses/{course_id}", headers=auth_headers(admin_token)) + assert delete_response.status_code == 200, delete_response.text + + deleted_associated_img = ( + db_session.query(AssociatedImg_DB).filter_by(associated_img_id=associated_img_id).one_or_none() + ) + deleted_course_document = ( + db_session.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() + ) + + assert deleted_associated_img is None + assert deleted_course_document is None + assert not os.path.exists(associated_img_path) + assert not os.path.exists(course_document_path) From ac298b11edf7e3543fded738bf594a61ec58b304 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Thu, 23 Apr 2026 12:13:44 +0000 Subject: [PATCH 22/26] Fix filename conflicts of course documents --- routes/course_document_router.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/routes/course_document_router.py b/routes/course_document_router.py index c9790fd..7bcd29d 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -50,6 +50,12 @@ async def create_course_document( if base_path is None: raise HTTPException(500, detail="Document base path is not configured") + if file.filename is None: + raise HTTPException(400, detail="The file has no name") + + # This helps avoid unnecessary filename conflicts. The filename is not often used anyway. + file.filename = course.title_urlized + "_" + file.filename + sanitized_filename, ext, file_path = await validate_file(base_path, file) course_document = CourseDocument_DB( From d9ae11ff30d3cb090681ae6f05658bad0f956b09 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Fri, 24 Apr 2026 15:16:47 +0000 Subject: [PATCH 23/26] Add created_course_code field to course document to store when it was created. --- api_schemas/course_document_schema.py | 1 + db_models/course_document_model.py | 2 ++ db_models/course_model.py | 2 +- routes/course_document_router.py | 1 + tests/test_course_documents.py | 17 +++++++++++++++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/api_schemas/course_document_schema.py b/api_schemas/course_document_schema.py index 7442996..3f8366a 100644 --- a/api_schemas/course_document_schema.py +++ b/api_schemas/course_document_schema.py @@ -11,6 +11,7 @@ class CourseDocumentRead(BaseSchema): title: str file_name: str course_id: int + created_course_code: str author: str category: COURSE_DOCUMENT_CATEGORIES sub_category: str | None diff --git a/db_models/course_document_model.py b/db_models/course_document_model.py index 2cc842a..791577c 100644 --- a/db_models/course_document_model.py +++ b/db_models/course_document_model.py @@ -22,6 +22,8 @@ class CourseDocument_DB(BaseModel_DB): course: Mapped["Course_DB"] = relationship(back_populates="documents", init=False) + created_course_code: Mapped[str] = mapped_column(String, nullable=False) + author: Mapped[str] = mapped_column(String(MAX_COURSE_DOC_AUTHOR), nullable=False) category: Mapped[COURSE_DOCUMENT_CATEGORIES] = mapped_column(default="Other") diff --git a/db_models/course_model.py b/db_models/course_model.py index 1552e1a..ae70320 100644 --- a/db_models/course_model.py +++ b/db_models/course_model.py @@ -25,7 +25,7 @@ class Course_DB(BaseModel_DB): title_urlized: Mapped[str] = mapped_column(String(MAX_COURSE_TITLE), unique=True) - course_code: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_CODE), default=None, unique=True) + course_code: Mapped[str] = mapped_column(String(MAX_COURSE_CODE), unique=True, nullable=False) description: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_DESC), default=None) diff --git a/routes/course_document_router.py b/routes/course_document_router.py index 7bcd29d..d53f2d2 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -65,6 +65,7 @@ async def create_course_document( author=data.author, category=data.category, sub_category=data.sub_category, + created_course_code=course.course_code, ) try: diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index ce8bbdb..1b2a69e 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -266,3 +266,20 @@ def test_member_cannot_delete_course_document( response = client.delete(f"/course-documents/{document_id}", headers=auth_headers(member_token)) assert response.status_code == 403 + + +def test_course_document_created_course_code( + client, + admin_token, + plugg_course_id, + example_file, +): + response = create_course_document( + client, + token=admin_token, + file=example_file, + **course_document_data_factory(course_id=plugg_course_id), + ) + assert response.status_code in (200, 201), response.text + data = response.json() + assert data["created_course_code"] == "EDAA01" From e6e210b08460a3caf57e690a355199e2d465150a Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sat, 25 Apr 2026 17:25:27 +0000 Subject: [PATCH 24/26] Update course updated_at when course documents are added, updated or deleted --- routes/course_document_router.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/routes/course_document_router.py b/routes/course_document_router.py index d53f2d2..926103c 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import os from pathlib import Path @@ -74,6 +75,10 @@ async def create_course_document( file_path.write_bytes(file.file.read()) db.commit() db.refresh(course_document) + + # If everything went well, update the course last updated timestamp + course.updated_at = course_document.created_at + db.commit() except IntegrityError: # Something went wrong with the DB db.rollback() @@ -106,6 +111,13 @@ def update_course_document(course_document_id: int, data: CourseDocumentUpdate, db.commit() db.refresh(course_document) + + # If everything went well, update the course last updated timestamp + course = db.query(Course_DB).filter_by(course_id=course_document.course_id).one_or_none() + if course is not None: + course.updated_at = course_document.updated_at + db.commit() + return course_document @@ -169,6 +181,12 @@ def delete_course_document(course_document_id: int, db: DB_dependency): db.delete(course_document) db.commit() os.remove(f"{base_path}/{course_document.file_name}") + + # If everything went well, update the course last updated timestamp + course = db.query(Course_DB).filter_by(course_id=course_document.course_id).one_or_none() + if course is not None: + course.updated_at = datetime.now(timezone.utc) + db.commit() except IntegrityError: db.rollback() raise HTTPException(500, detail="Something went wrong trying to delete the document, contact the Webmasters") From 721fc36a9fe622627a3fceddfd29e25f7e4ac54f Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Thu, 30 Apr 2026 09:44:05 +0000 Subject: [PATCH 25/26] Add short_identifier to make search better on frontend. Fix bugs related to previous course_id obligatory refactor. --- api_schemas/course_schema.py | 8 ++++++-- db_models/course_model.py | 2 ++ routes/course_router.py | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api_schemas/course_schema.py b/api_schemas/course_schema.py index dc53a6b..f43d18f 100644 --- a/api_schemas/course_schema.py +++ b/api_schemas/course_schema.py @@ -12,12 +12,14 @@ class SimpleCourseRead(BaseSchema): course_id: int title: str course_code: str | None + short_identifier: str | None # Kept so we can search for it class CourseRead(BaseSchema): course_id: int title: str course_code: str | None + short_identifier: str | None description: str | None associated_img_id: int | None documents: list[CourseDocumentRead] = [] @@ -28,11 +30,13 @@ class CourseRead(BaseSchema): class CourseCreate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] - course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None + course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] + short_identifier: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] | None = None description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None class CourseUpdate(BaseSchema): title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] - course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] | None = None + course_code: Annotated[str, StringConstraints(max_length=MAX_COURSE_CODE)] + short_identifier: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] | None = None description: Annotated[str, StringConstraints(max_length=MAX_COURSE_DESC)] | None = None diff --git a/db_models/course_model.py b/db_models/course_model.py index ae70320..faa8136 100644 --- a/db_models/course_model.py +++ b/db_models/course_model.py @@ -27,6 +27,8 @@ class Course_DB(BaseModel_DB): course_code: Mapped[str] = mapped_column(String(MAX_COURSE_CODE), unique=True, nullable=False) + short_identifier: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_TITLE), default=None) + description: Mapped[Optional[str]] = mapped_column(String(MAX_COURSE_DESC), default=None) program_year_courses: Mapped[list["ProgramYearCourse_DB"]] = relationship( diff --git a/routes/course_router.py b/routes/course_router.py index 71d5a54..1e91faf 100644 --- a/routes/course_router.py +++ b/routes/course_router.py @@ -86,7 +86,7 @@ def create_course(data: CourseCreate, db: DB_dependency): # Check for duplicate course code, since we require course codes to be unique. existing_course = db.query(Course_DB).filter_by(course_code=data.course_code).first() - if existing_course is not None and data.course_code is not None: + if existing_course is not None: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=f"Course with course_code {data.course_code} already exists, cannot create course", @@ -96,6 +96,7 @@ def create_course(data: CourseCreate, db: DB_dependency): title=data.title, title_urlized=normalized_title, course_code=data.course_code, + short_identifier=data.short_identifier, description=data.description, ) db.add(course) From 3b40941914e30b91894635c3f853c92ddb68cbc0 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Mon, 4 May 2026 14:36:59 +0000 Subject: [PATCH 26/26] Fix copilot suggested changes --- routes/course_document_router.py | 44 +++++++++++++++++++++--------- routes/document_router.py | 23 ++++++++++++++-- services/associated_img_service.py | 21 +++++++++++--- services/document_service.py | 1 - services/img_service.py | 23 +++++++++++----- tests/test_course_documents.py | 6 ++-- 6 files changed, 87 insertions(+), 31 deletions(-) diff --git a/routes/course_document_router.py b/routes/course_document_router.py index 926103c..df581d2 100644 --- a/routes/course_document_router.py +++ b/routes/course_document_router.py @@ -18,7 +18,6 @@ from services.document_service import validate_file from user.permission import Permission - course_document_router = APIRouter() @@ -27,8 +26,8 @@ def get_all_documents_from_course(course_id: int, db: DB_dependency): return db.query(CourseDocument_DB).filter_by(course_id=course_id).all() -@course_document_router.get("/{course_document_id}", response_model=CourseDocumentRead) -def get_course_document(course_document_id: int, db: DB_dependency): +@course_document_router.get("/object/{course_document_id}", response_model=CourseDocumentRead) +def get_course_document_object(course_document_id: int, db: DB_dependency): course_document = db.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() if course_document is None: raise HTTPException(status.HTTP_404_NOT_FOUND) @@ -49,7 +48,7 @@ async def create_course_document( base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") if base_path is None: - raise HTTPException(500, detail="Document base path is not configured") + raise HTTPException(500, detail="Course document base path is not configured") if file.filename is None: raise HTTPException(400, detail="The file has no name") @@ -124,6 +123,8 @@ def update_course_document(course_document_id: int, data: CourseDocumentUpdate, @course_document_router.get("/document_file/{course_document_id}") def get_course_document_file_by_id(course_document_id: int, db: DB_dependency): base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Course document base path is not configured") document = ( db.query(CourseDocument_DB).filter(CourseDocument_DB.course_document_id == course_document_id).one_or_none() @@ -145,6 +146,8 @@ def get_course_document_file( response: Response, ): base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Course document base path is not configured") document = ( db.query(CourseDocument_DB).filter(CourseDocument_DB.course_document_id == course_document_id).one_or_none() @@ -173,21 +176,36 @@ def get_course_document_file( ) def delete_course_document(course_document_id: int, db: DB_dependency): base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Course document base path is not configured") + course_document = db.query(CourseDocument_DB).filter_by(course_document_id=course_document_id).one_or_none() if course_document is None: raise HTTPException(status.HTTP_404_NOT_FOUND) try: db.delete(course_document) - db.commit() - os.remove(f"{base_path}/{course_document.file_name}") - - # If everything went well, update the course last updated timestamp - course = db.query(Course_DB).filter_by(course_id=course_document.course_id).one_or_none() - if course is not None: - course.updated_at = datetime.now(timezone.utc) - db.commit() + db.flush() except IntegrityError: db.rollback() - raise HTTPException(500, detail="Something went wrong trying to delete the document, contact the Webmasters") + raise HTTPException( + 500, + detail="Something went wrong trying to delete the course document from the database, contact the Webmasters", + ) + + try: + os.remove(f"{base_path}/{course_document.file_name}") + except OSError: + db.rollback() + raise HTTPException( + 500, detail="Something went wrong trying to delete the course document file, contact the Webmasters" + ) + + # If everything went well, update the course last updated timestamp + course = db.query(Course_DB).filter_by(course_id=course_document.course_id).one_or_none() + if course is not None: + course.updated_at = datetime.now(timezone.utc) + + db.commit() + return course_document diff --git a/routes/document_router.py b/routes/document_router.py index d4420ae..324cb36 100644 --- a/routes/document_router.py +++ b/routes/document_router.py @@ -88,6 +88,8 @@ def get_document_file_by_id( document_id: int, db: DB_dependency, member_permission: Annotated[bool, Permission.check_member()] ): base_path = os.getenv("DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Document base path is not configured") document = db.query(Document_DB).filter(Document_DB.id == document_id).one_or_none() if document is None: @@ -111,6 +113,8 @@ def get_document_file( response: Response, ): base_path = os.getenv("DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Document base path is not configured") document = db.query(Document_DB).filter(Document_DB.id == document_id).one_or_none() if document is None: @@ -140,6 +144,8 @@ def get_document_file( ) def delete_document(document_id: int, db: DB_dependency): base_path = os.getenv("DOCUMENT_BASE_PATH") + if base_path is None: + raise HTTPException(500, detail="Document base path is not configured") document = db.query(Document_DB).filter(Document_DB.id == document_id).one_or_none() if document is None: @@ -147,12 +153,23 @@ def delete_document(document_id: int, db: DB_dependency): try: db.delete(document) - db.commit() + db.flush() + except IntegrityError: + db.rollback() + raise HTTPException( + 500, detail="Something went wrong trying to delete the document from the database, contact the Webmasters" + ) + + try: # Only delete the file if the database deletion was successful os.remove(f"{base_path}/{document.file_name}") - except IntegrityError: + except OSError: db.rollback() - raise HTTPException(500, detail="Something went wrong trying to delete the document, contact the Webmasters") + raise HTTPException( + 500, detail="Something went wrong trying to delete the document file, contact the Webmasters" + ) + + db.commit() return document diff --git a/services/associated_img_service.py b/services/associated_img_service.py index 4f65cc1..369bf3c 100644 --- a/services/associated_img_service.py +++ b/services/associated_img_service.py @@ -75,12 +75,20 @@ def upload_img( try: db.add(img) - db.commit() + db.flush() except IntegrityError: db.rollback() - raise HTTPException(400, detail="Invalid tag name") + raise HTTPException( + 400, detail="Something went wrong when trying to create the associated image in the database" + ) - file_path.write_bytes(file.file.read()) + try: + file_path.write_bytes(file.file.read()) + except OSError: + db.rollback() + raise HTTPException(500, detail="Error saving file") + + db.commit() return {"message": "File saved successfully"} @@ -100,7 +108,12 @@ def remove_img(db: Session, img_id: int): if img.specialisation is not None: img.specialisation.associated_img = None - os.remove(img.path) + try: + path = Path(img.path) + path.unlink(missing_ok=True) + except OSError: + raise HTTPException(500, detail="Error deleting file") + db.delete(img) db.commit() diff --git a/services/document_service.py b/services/document_service.py index a2dae7a..c9c1e3f 100644 --- a/services/document_service.py +++ b/services/document_service.py @@ -1,6 +1,5 @@ # Used both in document_router and in course_document_router. -from fastapi import HTTPException from helpers.constants import MAX_DOC_TITLE, MAX_FILE_SIZE_MB from helpers.pdf_checker import validate_pdf_header from fastapi import File, UploadFile, HTTPException diff --git a/services/img_service.py b/services/img_service.py index 26a9368..9305e5c 100644 --- a/services/img_service.py +++ b/services/img_service.py @@ -11,10 +11,8 @@ from sqlalchemy.exc import IntegrityError -base_path = os.getenv("ALBUM_BASE_PATH") - - def upload_img(db: Session, album_id: int, file: UploadFile = File()): + base_path = os.getenv("ALBUM_BASE_PATH") if base_path is None: raise HTTPException(500, detail="Server configuration error: ALBUM_BASE_PATH not set") @@ -58,12 +56,18 @@ def upload_img(db: Session, album_id: int, file: UploadFile = File()): try: db.add(img) - db.commit() + db.flush() except IntegrityError: db.rollback() - raise HTTPException(400, detail="Invalid tag name") + raise HTTPException(400, detail="Error creating image in the database") + + try: + file_path.write_bytes(file.file.read()) + except OSError: + db.rollback() + raise HTTPException(500, detail="Error saving image to disk") - file_path.write_bytes(file.file.read()) + db.commit() return {"message": "File saved successfully"} @@ -74,7 +78,12 @@ def remove_img(db: Session, img_id: int): if img == None: raise HTTPException(404, detail="File not found") - os.remove(img.path) + try: + path = Path(img.path) + path.unlink(missing_ok=True) + except OSError: + raise HTTPException(500, detail="Error deleting file") + db.delete(img) db.commit() diff --git a/tests/test_course_documents.py b/tests/test_course_documents.py index 1b2a69e..0ea3035 100644 --- a/tests/test_course_documents.py +++ b/tests/test_course_documents.py @@ -117,7 +117,7 @@ def test_get_course_document_by_id_public(client, admin_token, plugg_course_id, assert create_response.status_code in (200, 201), create_response.text document_id = create_response.json()["course_document_id"] - response = client.get(f"/course-documents/{document_id}") + response = client.get(f"/course-documents/object/{document_id}") assert response.status_code == 200 assert response.json()["course_document_id"] == document_id @@ -238,13 +238,13 @@ def test_delete_course_document_success(client, admin_token, plugg_course_id, ex assert create_response.status_code in (200, 201), create_response.text document_id = create_response.json()["course_document_id"] - get_response = client.get(f"/course-documents/{document_id}") + get_response = client.get(f"/course-documents/object/{document_id}") assert get_response.status_code == 200 delete_response = client.delete(f"/course-documents/{document_id}", headers=auth_headers(admin_token)) assert delete_response.status_code == 200 - get_response = client.get(f"/course-documents/{document_id}") + get_response = client.get(f"/course-documents/object/{document_id}") assert get_response.status_code == 404