diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 194512a..917dc3b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -25,13 +25,17 @@ 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 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/api_schemas/associated_img_schema.py b/api_schemas/associated_img_schema.py new file mode 100644 index 0000000..c40211b --- /dev/null +++ b/api_schemas/associated_img_schema.py @@ -0,0 +1,15 @@ +from fastapi import UploadFile +from api_schemas.base_schema import BaseSchema + + +class AssociatedImgRead(BaseSchema): + associated_img_id: int + path: str + + +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 new file mode 100644 index 0000000..3f8366a --- /dev/null +++ b/api_schemas/course_document_schema.py @@ -0,0 +1,51 @@ +from typing import Annotated +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 +from helpers.types import COURSE_DOCUMENT_CATEGORIES, datetime_utc + + +class CourseDocumentRead(BaseSchema): + course_document_id: int + title: str + file_name: str + course_id: int + created_course_code: str + 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)] + 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 + + +# 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(...), + course_id: int = Form(...), + author: str = Form(...), + category: COURSE_DOCUMENT_CATEGORIES = Form("Other"), + sub_category: str | None = Form(None), +) -> CourseDocumentCreate: + return CourseDocumentCreate( + title=title, + course_id=course_id, + author=author, + category=category, + sub_category=sub_category, + ) + + +class CourseDocumentUpdate(BaseSchema): + title: Annotated[str, StringConstraints(max_length=MAX_DOC_TITLE)] + 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 new file mode 100644 index 0000000..f43d18f --- /dev/null +++ b/api_schemas/course_schema.py @@ -0,0 +1,42 @@ +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 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 + + +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] = [] + updated_at: datetime_utc + program_years: list[SimpleProgramYearRead] = [] + specialisations: list[SimpleSpecialisationRead] = [] + + +class CourseCreate(BaseSchema): + title: Annotated[str, StringConstraints(max_length=MAX_COURSE_TITLE)] + 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)] + 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/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 new file mode 100644 index 0000000..87bb712 --- /dev/null +++ b/api_schemas/program_schema.py @@ -0,0 +1,44 @@ +from typing import Annotated +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.course_schema import ( + CourseRead, # 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): + program_id: int + title_sv: str + title_en: str + description_sv: str | None + description_en: str | None + associated_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 + specialisation_ids: list[int] = [] + + +class ProgramUpdate(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 + specialisation_ids: list[int] = [] diff --git a/api_schemas/program_year_schema.py b/api_schemas/program_year_schema.py new file mode 100644 index 0000000..00a6153 --- /dev/null +++ b/api_schemas/program_year_schema.py @@ -0,0 +1,43 @@ +from typing import Annotated, TYPE_CHECKING +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from helpers.constants import MAX_PROGRAM_YEAR_DESC, MAX_PROGRAM_YEAR_TITLE + +if TYPE_CHECKING: + from api_schemas.course_schema import CourseRead + + +class SimpleProgramYearRead(BaseSchema): + program_year_id: int + title_sv: str + title_en: str + program_id: int + + +class ProgramYearRead(BaseSchema): + program_year_id: int + title_sv: str + title_en: str + program_id: int + description_sv: str | None + description_en: str | None + associated_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 + 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 + + +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 new file mode 100644 index 0000000..f2c061e --- /dev/null +++ b/api_schemas/specialisation_schema.py @@ -0,0 +1,41 @@ +from typing import Annotated, TYPE_CHECKING +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +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 CourseRead + + +class SimpleSpecialisationRead(BaseSchema): + specialisation_id: int + title_sv: str + title_en: str + + +class SpecialisationRead(BaseSchema): + specialisation_id: int + title_sv: str + title_en: str + programs: list["SimpleProgramRead"] = [] + description_sv: str | None + description_en: str | None + associated_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)] + 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 + + +class SpecialisationUpdate(BaseSchema): + title_sv: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] + title_en: Annotated[str, StringConstraints(max_length=MAX_SPECIALISATION_TITLE)] + 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/associated_img_model.py b/db_models/associated_img_model.py new file mode 100644 index 0000000..1e655e3 --- /dev/null +++ b/db_models/associated_img_model.py @@ -0,0 +1,33 @@ +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_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="associated_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="associated_img", init=False, uselist=False) + + specialisation: Mapped[Optional["Specialisation_DB"]] = relationship( + back_populates="associated_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..791577c --- /dev/null +++ b/db_models/course_document_model.py @@ -0,0 +1,35 @@ +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) + + 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") + + # 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..faa8136 --- /dev/null +++ b/db_models/course_model.py @@ -0,0 +1,62 @@ +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 +from helpers.types import datetime_utc +from helpers.db_util import latest_modified_column + +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)) + + title_urlized: Mapped[str] = mapped_column(String(MAX_COURSE_TITLE), unique=True) + + 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( + 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 + ) + + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) + + 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 + ) + + updated_at: Mapped[datetime_utc] = latest_modified_column() diff --git a/db_models/program_model.py b/db_models/program_model.py new file mode 100644 index 0000000..24a62d4 --- /dev/null +++ b/db_models/program_model.py @@ -0,0 +1,49 @@ +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 + +if TYPE_CHECKING: + 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): + __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_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) + + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) + + 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 + ) + + 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/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..a1125a6 --- /dev/null +++ b/db_models/program_year_model.py @@ -0,0 +1,55 @@ +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, UniqueConstraint +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" + # 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) + + 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) + + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) + + 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 + ) + 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..2b2d981 --- /dev/null +++ b/db_models/specialisation_model.py @@ -0,0 +1,54 @@ +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 + from .program_specialisation_model import ProgramSpecialisation_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_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 + ) + + 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) + + description_en: Mapped[Optional[str]] = mapped_column(String(MAX_SPECIALISATION_DESC), default=None) + + associated_img_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("associated_img_table.associated_img_id"), default=None + ) + + 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 + ) + 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 f95b334..1ed428f 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -102,6 +102,26 @@ 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 = 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 +MAX_COURSE_DOC_AUTHOR = 50 +MAX_COURSE_DOC_SUB_CATEGORY = 50 # Keyval, used for example for storing the links to different very important documents (e.g. reglementet) MAX_KEYVAL_KEY = 100 diff --git a/helpers/types.py b/helpers/types.py index 34af1a1..4495e7b 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -59,6 +59,8 @@ def force_utc(date: datetime): "Moosegame", "MailAlias", "GuildMeeting", + "Plugg", + "AssociatedImg", "Keyvals", ] @@ -109,3 +111,9 @@ 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"] + +# Used for associated images +ASSOCIATION_TYPES = Literal["program", "program_year", "course", "specialisation"] 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/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, diff --git a/routes/__init__.py b/routes/__init__.py index f7bef5f..aa2fc99 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -31,6 +31,12 @@ 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 +from .associated_img_router import associated_img_router from .keyval_router import keyval_router # here comes the big momma router @@ -96,4 +102,16 @@ 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"]) + +main_router.include_router(associated_img_router, prefix="/associated-img", tags=["associated img"]) + main_router.include_router(keyval_router, prefix="/keyvals", tags=["keyvals"]) diff --git a/routes/associated_img_router.py b/routes/associated_img_router.py new file mode 100644 index 0000000..3d94556 --- /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_associated_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_associated_image(db: DB_dependency, id: int): + return remove_img(db, id) + + +@associated_img_router.get("/stream/{img_id}") +def get_associated_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_associated_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/routes/course_document_router.py b/routes/course_document_router.py new file mode 100644 index 0000000..df581d2 --- /dev/null +++ b/routes/course_document_router.py @@ -0,0 +1,211 @@ +from datetime import datetime, timezone +import os +from pathlib import Path + +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, + 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 +from services.document_service import validate_file +from user.permission import Permission + +course_document_router = APIRouter() + + +@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("/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) + return course_document + + +@course_document_router.post( + "/", response_model=CourseDocumentRead, dependencies=[Permission.require("manage", "Plugg")] +) +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") + + base_path = os.getenv("COURSE_DOCUMENT_BASE_PATH") + if base_path is None: + 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") + + # 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( + 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, + created_course_code=course.course_code, + ) + + try: + db.add(course_document) + db.flush() + 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() + 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 + + +@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) + + # 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 + + +@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() + ) + 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") + 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() + ) + 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") + 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.flush() + except IntegrityError: + db.rollback() + 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/course_router.py b/routes/course_router.py new file mode 100644 index 0000000..1e91faf --- /dev/null +++ b/routes/course_router.py @@ -0,0 +1,168 @@ +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 +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 +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() + + +@course_router.get("/", response_model=list[CourseRead]) +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) + 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() + 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): + 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).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).first() + 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", + ) + + course = Course_DB( + title=data.title, + title_urlized=normalized_title, + course_code=data.course_code, + short_identifier=data.short_identifier, + description=data.description, + ) + db.add(course) + + 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) + + 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).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).first() + 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", + ) + + for var, value in vars(data).items(): + # Note that we always set None values, to clear fields if the user wants to. + setattr(course, var, value) + + course.title_urlized = normalized_title + + 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) + + 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/document_router.py b/routes/document_router.py index a36f89b..324cb36 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, @@ -80,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 @@ -108,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: @@ -131,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: @@ -160,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: @@ -167,11 +153,23 @@ def delete_document(document_id: int, db: DB_dependency): try: db.delete(document) - db.commit() + db.flush() except IntegrityError: - raise HTTPException(500, detail="Something went wrong trying to delete the document, contact the Webmasters") + db.rollback() + raise HTTPException( + 500, detail="Something went wrong trying to delete the document from the database, contact the Webmasters" + ) - os.remove(f"{base_path}/{document.file_name}") + try: + # Only delete the file if the database deletion was successful + os.remove(f"{base_path}/{document.file_name}") + except OSError: + db.rollback() + raise HTTPException( + 500, detail="Something went wrong trying to delete the document file, contact the Webmasters" + ) + + db.commit() return document diff --git a/routes/program_router.py b/routes/program_router.py new file mode 100644 index 0000000..92d636a --- /dev/null +++ b/routes/program_router.py @@ -0,0 +1,184 @@ +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 +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, +) +from services.plugg_cleanup_service import collect_orphaned_associated_img_path_after_detach, remove_files + + +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("/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() + 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): + 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 + + +@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) + + 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(): + 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) + 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) + + # 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 new file mode 100644 index 0000000..676ce95 --- /dev/null +++ b/routes/program_year_router.py @@ -0,0 +1,212 @@ +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 +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 +from services.plugg_cleanup_service import collect_orphaned_associated_img_path_after_detach, remove_files + + +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("/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) + 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() + 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): + 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 + + +@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) + + 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(): + 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) + 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) + + 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 new file mode 100644 index 0000000..5d63559 --- /dev/null +++ b/routes/specialisation_router.py @@ -0,0 +1,197 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError + +from api_schemas.specialisation_schema import ( + SpecialisationCreate, + SpecialisationRead, + SpecialisationUpdate, +) +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 +from services.plugg_cleanup_service import collect_orphaned_associated_img_path_after_detach, remove_files + + +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("/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) + 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() + 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): + 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 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. + db.flush() + + # 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) + 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) + + 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 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 != "course_ids": + # Note that we always set None values, to clear fields if the user wants to. + setattr(specialisation, var, value) + + 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) + 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) + + 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/seed.py b/seed.py index 658e333..f64edb9 100644 --- a/seed.py +++ b/seed.py @@ -221,7 +221,11 @@ 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"]), 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"]), ] [ diff --git a/services/associated_img_service.py b/services/associated_img_service.py new file mode 100644 index 0000000..369bf3c --- /dev/null +++ b/services/associated_img_service.py @@ -0,0 +1,129 @@ +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.associated_img = img + + try: + db.add(img) + db.flush() + except IntegrityError: + db.rollback() + raise HTTPException( + 400, detail="Something went wrong when trying to create the associated image in the database" + ) + + 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"} + + +def remove_img(db: Session, img_id: int): + 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.associated_img = None + if img.program_year is not None: + img.program_year.associated_img = None + if img.course is not None: + img.course.associated_img = None + if img.specialisation is not None: + img.specialisation.associated_img = None + + try: + path = Path(img.path) + path.unlink(missing_ok=True) + except OSError: + raise HTTPException(500, detail="Error deleting file") + + 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_img_id == img_id).one_or_none() + + if img == None: + raise HTTPException(404, detail="File not found") + + return FileResponse(img.path) diff --git a/services/document_service.py b/services/document_service.py new file mode 100644 index 0000000..c9c1e3f --- /dev/null +++ b/services/document_service.py @@ -0,0 +1,46 @@ +# Used both in document_router and in course_document_router. + +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/services/img_service.py b/services/img_service.py index 8f3a0b4..9305e5c 100644 --- a/services/img_service.py +++ b/services/img_service.py @@ -11,10 +11,10 @@ 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") if file.filename is None: raise HTTPException(400, detail="The file has no name") @@ -56,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"} @@ -72,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/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/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 new file mode 100644 index 0000000..2f4711d --- /dev/null +++ b/services/specialisation_service.py @@ -0,0 +1,64 @@ +from database import DB_dependency +from db_models.course_model import Course_DB +from db_models.specialisation_model import Specialisation_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_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_specialisation_course_associations( + specialisation: Specialisation_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(SpecialisationCourse_DB) + .filter_by(specialisation_id=specialisation.specialisation_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(SpecialisationCourse_DB(specialisation_id=specialisation.specialisation_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(SpecialisationCourse_DB) + .filter( + SpecialisationCourse_DB.specialisation_id == specialisation.specialisation_id, + SpecialisationCourse_DB.course_id.in_(course_ids_to_remove), + ) + .delete(synchronize_session=False) + ) + + return specialisation diff --git a/tests/basic_factories.py b/tests/basic_factories.py index 39a1bed..69af3f1 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -128,3 +128,112 @@ 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, + "course_ids": [], + "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(**kwargs): + """Factory for creating specialisation payloads.""" + default_data = { + "title_sv": "Maskininlarning", + "title_en": "Machine learning", + "course_ids": [], + "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(**kwargs): + """Factory for creating course payloads.""" + default_data = { + "title": "Lineär algebra", + "course_code": "FMAA01", + "description": "Hoppas du gillar matriser", + } + 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) + + +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 2169124..c1e3358 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -98,6 +98,9 @@ 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"), + Permission_DB(action="manage", target="AssociatedImg"), Permission_DB(action="view", target="Keyvals"), Permission_DB(action="manage", target="Keyvals"), ] @@ -239,6 +242,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 516e7c4..d0a2e03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,8 +23,10 @@ # 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" +os.environ["ASSOCIATED_IMG_BASE_PATH"] = "/workspaces/WebWebWeb/pytest-assets/associated_images" @pytest.fixture(scope="session", autouse=True) @@ -73,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) @@ -86,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 @@ -104,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 new file mode 100644 index 0000000..4303ca8 --- /dev/null +++ b/tests/test_associated_img.py @@ -0,0 +1,310 @@ +# type: ignore +import asyncio +import os +import pytest + +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()["associated_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"] + + course_response = create_course( + client, + token=admin_token, + ) + 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, + "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()["associated_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_img_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_img_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_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()["associated_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_img_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 + + +# 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 new file mode 100644 index 0000000..0ea3035 --- /dev/null +++ b/tests/test_course_documents.py @@ -0,0 +1,285 @@ +# type: ignore +import os +import pytest +from pathlib import Path + +from .basic_factories import ( + auth_headers, + course_document_data_factory, + create_course_document, +) +from helpers.url_formatter import url_formatter + + +@pytest.fixture +def plugg_course_id(db_session): + from db_models.course_model import Course_DB + + course = Course_DB( + title="Grundkurs i programmering", + title_urlized=url_formatter("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): + 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 in (200, 201), response.text + data = response.json() + 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") + + +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, + 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", + "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["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/object/{document_id}") + assert response.status_code == 200 + assert response.json()["course_document_id"] == document_id + + +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, + 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/course/{plugg_course_id}") + 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/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/object/{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 + + +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" 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 new file mode 100644 index 0000000..7a76149 --- /dev/null +++ b/tests/test_plugg_resources.py @@ -0,0 +1,1224 @@ +# 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, + program_data_factory, + program_year_data_factory, + specialisation_data_factory, +) + +from helpers.url_formatter import url_formatter + + +@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): + # 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) + 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_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_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) + 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_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) + 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_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 + + +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() + assert data["programs"] == [] + assert data["title_sv"] == expected["title_sv"] + assert "specialisation_id" in data + + +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_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( + 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): + 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"] + + 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 + + +def test_create_program_with_multiple_specialisations_success(client, admin_token): + first_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Inbyggda system", title_en="Embedded systems"), + ) + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] + + 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"] == "Teknisk fysik" + assert {specialisation["specialisation_id"] for specialisation in data["specialisations"]} == { + first_specialisation_id, + second_specialisation_id, + } + + +def test_update_program_specialisation_associations_success(client, admin_token): + first_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="AI och data", title_en="AI and data"), + ) + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] + + second_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Signalbehandling", title_en="Signal processing"), + ) + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] + + 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 + program_id = create_response.json()["program_id"] + + update_response = client.patch( + f"/programs/{program_id}", + json={ + "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", + }, + headers=auth_headers(admin_token), + ) + + assert update_response.status_code == 200, update_response.text + updated = update_response.json() + 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_reads_include_specialisation_associations( + client, + admin_token, + db_session, +): + first_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Data science", title_en="Data science"), + ) + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] + + second_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="AI", title_en="AI"), + ) + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_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() + + 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, + } + + +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"] + + second_specialisation_response = create_specialisation( + client, + token=admin_token, + **specialisation_data_factory(title_sv="Bioteknik", title_en="Biotechnology"), + ) + assert second_specialisation_response.status_code in (200, 201), second_specialisation_response.text + second_specialisation_id = second_specialisation_response.json()["specialisation_id"] + + 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("/programs/") + assert before_list.status_code == 200, before_list.text + before_programs = before_list.json() + before_count = len(before_programs) + + create_response = create_program(client, token=admin_token, **payload) + assert create_response.status_code == 404 + + after_list = client.get("/programs/") + assert after_list.status_code == 200, after_list.text + after_programs = after_list.json() + + assert len(after_programs) == before_count + assert all(program["title_en"] != payload["title_en"] for program in after_programs) + + +def test_update_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="Signalbehandling", title_en="Signal processing"), + ) + assert first_specialisation_response.status_code in (200, 201), first_specialisation_response.text + first_specialisation_id = first_specialisation_response.json()["specialisation_id"] + + 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"] + + 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_program = create_response.json() + program_id = created_program["program_id"] + + 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"/programs/{program_id}", + json=update_payload, + headers=auth_headers(admin_token), + ) + assert update_response.status_code == 404 + + 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_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): + token = request.getfixturevalue(token_fixture) if token_fixture else None + + response = create_specialisation(client, token=token) + assert response.status_code == expected_status + + +def test_create_course_success(client, admin_token): + payload = course_data_factory( + title="Diskret matematik", + course_code="FMAB10", + description="Relations and graphs", + ) + + 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 data["program_years"] == [] + assert data["specialisations"] == [] + + +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"] + + 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_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, + 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 + + +def test_create_program_year_with_course_relationships_success( + client, + admin_token, + base_program, +): + 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"] + + 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 + + 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], + ) + + before_list = client.get("/program-years/") + assert before_list.status_code == 200, before_list.text + before_count = len(before_list.json()) + + response = create_program_year(client, token=admin_token, **payload) + assert response.status_code == 404 + + after_list = client.get("/program-years/") + assert after_list.status_code == 200, after_list.text + after_program_years = after_list.json() + + 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_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"] + + second_course_response = create_course( + client, + token=admin_token, + **course_data_factory(title="Flervariabelanalys forts", course_code="FMAB13"), + ) + assert second_course_response.status_code in (200, 201), second_course_response.text + second_course_id = second_course_response.json()["course_id"] + + create_program_year_response = create_program_year( + client, + token=admin_token, + **program_year_data_factory( + program_id=base_program["program_id"], + title_sv="Årskurs 4", + title_en="Year 4", + course_ids=[first_course_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"] + + 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="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 + 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 = 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"/specialisations/{specialisation_id}", + json=update_payload, + headers=auth_headers(admin_token), + ) + assert update_response.status_code == 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 {course["course_id"] for course in detail_data["courses"]} == {first_course_id} + + +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", + ), + ) + 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, +): + token = request.getfixturevalue(token_fixture) if token_fixture else None + + response = create_course( + client, + token=token, + **course_data_factory( + title="Operativsystem", + course_code="EDA092", + ), + ) + assert response.status_code == expected_status + + +def test_delete_course_success(client, admin_token): + create_response = create_course( + client, + token=admin_token, + **course_data_factory( + title="Datastrukturer", + course_code="EDA123", + ), + ) + 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 + + +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)