Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
957514e
Add models
georgelgeback Mar 20, 2026
6857953
Add preliminary schemas
georgelgeback Mar 21, 2026
b5be87f
Routers added
georgelgeback Mar 23, 2026
3ee952f
Fix circular schema import errors preventing compilation
georgelgeback Apr 12, 2026
ca14db7
Add tests
georgelgeback Apr 12, 2026
97feefb
Fix course service handling of many-to-many objects to make tests pass
georgelgeback Apr 12, 2026
6f5aee1
Update course_document to fix tests and better match how we manage re…
georgelgeback Apr 13, 2026
285069c
Change get all course docs route to get all course docs from a specif…
georgelgeback Apr 13, 2026
62b1a8b
Finish implementation of associated images, with tests
georgelgeback Apr 13, 2026
f3d6997
Change naming to be more consistent. Associated images should be refe…
georgelgeback Apr 13, 2026
9180b87
Merge remote-tracking branch 'origin/main' into plugg-update
georgelgeback Apr 13, 2026
33cf360
Raise limits for course titles and codes
georgelgeback Apr 14, 2026
e99cc54
Make it possible to have many programs for each specialisation by swi…
georgelgeback Apr 14, 2026
39e67b8
Check for duplicate course codes and add more permissions to seeding …
georgelgeback Apr 14, 2026
3010b7f
disambiguate name of associated_img_router
georgelgeback Apr 15, 2026
17f79ec
Send error status codes (e.g. 404) to the frontend instead of just th…
georgelgeback Apr 15, 2026
067fd09
Plugg Schemas now make more sense
georgelgeback Apr 15, 2026
8b05b97
Flip many-many plugg relationship directions to simplify frontend and…
georgelgeback Apr 17, 2026
b4d1f6b
Add back program_id to simpleprogramyearread
georgelgeback Apr 17, 2026
1ada25f
Changes to types and required fields based on frontend realities,
georgelgeback Apr 18, 2026
300ba3b
Fix bugs: file writes persisting after db problem or db object persis…
georgelgeback Apr 18, 2026
c7fb337
Add more edge case tests. Fix removal of associated_img files when pa…
georgelgeback Apr 23, 2026
ac298b1
Fix filename conflicts of course documents
georgelgeback Apr 23, 2026
d9ae11f
Add created_course_code field to course document to store when it was…
georgelgeback Apr 24, 2026
e6e210b
Update course updated_at when course documents are added, updated or …
georgelgeback Apr 25, 2026
721fc36
Add short_identifier to make search better on frontend. Fix bugs rela…
georgelgeback Apr 30, 2026
3b40941
Fix copilot suggested changes
georgelgeback May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions api_schemas/associated_img_schema.py
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions api_schemas/course_document_schema.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions api_schemas/course_schema.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion api_schemas/document_schema.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
44 changes: 44 additions & 0 deletions api_schemas/program_schema.py
Original file line number Diff line number Diff line change
@@ -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] = []
43 changes: 43 additions & 0 deletions api_schemas/program_year_schema.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions api_schemas/specialisation_schema.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions db_models/associated_img_model.py
Original file line number Diff line number Diff line change
@@ -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
)
35 changes: 35 additions & 0 deletions db_models/course_document_model.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading