diff --git a/timetable/api.py b/timetable/api.py new file mode 100644 index 00000000..1a1a957d --- /dev/null +++ b/timetable/api.py @@ -0,0 +1,37 @@ +from ninja.security import SessionAuth +from ninja_extra import ControllerBase, api_controller, route +from ninja_extra.exceptions import PermissionDenied + +from core.utils import get_semester_code +from timetable.models import Timetable, TimetableSlot +from timetable.schemas import CreateTimetableSchema, TimetableSchema + + +@api_controller("/edt", urls_namespace="timetable") +class TimetableController(ControllerBase): + @route.put( + "/{int:user_id}", + auth=SessionAuth(), + response={200: TimetableSchema}, + url_name="save", + ) + def save_timetable(self, user_id: int, data: CreateTimetableSchema): + user = self.context.request.user + if not user.has_perm("pedagogy.add_timetable") and not ( + user.has_perm("timetable.add_self_timetable") and user.id == user_id + ): + raise PermissionDenied + timetable, created = Timetable.objects.get_or_create( + semester=get_semester_code(), user_id=user_id + ) + if not created: + timetable.slots.all().delete() + if user.preferences.show_my_timetable != data.is_viewable: + user.preferences.show_my_timetable = data.is_viewable + user.preferences.save() + slots = TimetableSlot.objects.bulk_create( + [TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots] + ) + return TimetableSchema( + id=timetable.id, semester=timetable.semester, slots=slots + ) diff --git a/timetable/schemas.py b/timetable/schemas.py new file mode 100644 index 00000000..a5563b4f --- /dev/null +++ b/timetable/schemas.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from annotated_types import Ge, Le +from ninja import ModelSchema, Schema +from pydantic import Field + +from timetable.models import Timetable, TimetableSlot + + +class TimetableSlotSchema(ModelSchema): + weekday: TimetableSlot.WeekDay = Field( + description=( + f"{TimetableSlot.WeekDay.MONDAY} is monday, " + f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc." + ) + ) + start_at: Annotated[int, Ge(0), Le(96)] + end_at: Annotated[int, Ge(0), Le(96)] + + class Meta: + model = TimetableSlot + fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"] + + +class CreateTimetableSchema(Schema): + is_viewable: bool + slots: list[TimetableSlotSchema] + + +class TimetableSchema(ModelSchema): + slots: list[TimetableSlotSchema] + + class Meta: + model = Timetable + fields = ["id", "semester"] diff --git a/timetable/static/bundled/timetable/generator-index.ts b/timetable/static/bundled/timetable/generator-index.ts index 51438094..54c48e07 100644 --- a/timetable/static/bundled/timetable/generator-index.ts +++ b/timetable/static/bundled/timetable/generator-index.ts @@ -1,4 +1,6 @@ import html2canvas from "html2canvas"; +import { AlertMessage } from "#core:utils/alert-message"; +import { type WeekDay as ApiWeekDay, timetableSaveTimetable } from "#openapi"; // see https://regex101.com/r/QHSaPM/3 const TIMETABLE_ROW_RE: RegExp = @@ -78,7 +80,76 @@ function parseSlots(s: string): TimetableSlot[] { document.addEventListener("alpine:init", () => { Alpine.data("timetableGenerator", () => ({ content: DEFAULT_TIMETABLE, - error: null as { incorrectRow?: string }, + error: null as { incorrectRow?: string } | null, + courses: [] as TimetableSlot[], + edtPublic: false, + saving: false, + timetableSavedMessage: new AlertMessage(), + + generate() { + try { + this.courses = parseSlots(this.content); + this.error = null; + } catch (err) { + type ParseRowError = { cause: { row: string } }; + this.error = { incorrectRow: (err as ParseRowError)?.cause?.row }; + return; + } + }, + + async savePng() { + const elem = document.getElementById("timetable"); + const img = (await html2canvas(elem as HTMLElement)).toDataURL(); + const downloadLink = document.createElement("a"); + downloadLink.href = img; + downloadLink.download = "edt.png"; + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + }, + + async saveSith() { + this.saving = true; + const res = await timetableSaveTimetable({ + body: { + // biome-ignore lint/style/useNamingConvention: api is snake case + is_viewable: this.edtPublic, + slots: this.courses.map((c) => ({ + // biome-ignore lint/style/useNamingConvention: api is snake case + start_at: c.startSlot, + // biome-ignore lint/style/useNamingConvention: api is snake case + end_at: c.endSlot, + weekday: (WEEKDAYS.indexOf(c.weekday) + 1) as ApiWeekDay, + // biome-ignore lint/style/useNamingConvention: api is snake case + week_group: c.weekGroup, + // biome-ignore lint/style/useNamingConvention: api is snake case + course_type: c.courseType, + room: c.room, + ue: c.ueCode, + })), + }, + }); + if (res.response?.ok) { + this.timetableSavedMessage.display( + gettext("This timetable has been saved in your profile"), + { success: true }, + ); + } else { + this.timetableSavedMessage.display( + interpolate(gettext("Error %d: timetable save failed"), res.response?.status), + { success: false }, + ); + } + this.saving = false; + }, + })); + + /** + * Alpine data dedicated to timetable display only. + * It can interact with the `timetableGenerator` data with x-model and x-modelable + * (for example of this, see timetable/generator.jinja) + */ + Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({ displayedWeekdays: [] as WeekDay[], courses: [] as TimetableSlot[], startSlot: 0, @@ -108,7 +179,8 @@ document.addEventListener("alpine:init", () => { this.courses = parseSlots(this.content); this.error = null; } catch (err) { - this.error = { incorrectRow: err?.cause?.row }; + type ParseRowError = { cause: { row: string } }; + this.error = { incorrectRow: (err as ParseRowError)?.cause?.row }; return; } @@ -138,7 +210,7 @@ document.addEventListener("alpine:init", () => { }, getStyle(slot: TimetableSlot) { - const hasWeekGroup = slot.weekGroup !== undefined; + const hasWeekGroup = !!slot.weekGroup; const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH; const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0; return {