From a33a4da1434cc26571197f92d8a45a33984cbda7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 29 Sep 2026 14:47:44 +0200 Subject: [PATCH 1/9] add models --- .../0052_preferences_show_my_timetable.py | 22 +++ core/models.py | 8 + timetable/admin.py | 16 ++ timetable/migrations/0001_initial.py | 141 ++++++++++++++++++ timetable/models.py | 101 +++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 core/migrations/0052_preferences_show_my_timetable.py create mode 100644 timetable/migrations/0001_initial.py diff --git a/core/migrations/0052_preferences_show_my_timetable.py b/core/migrations/0052_preferences_show_my_timetable.py new file mode 100644 index 00000000..eb9f37bf --- /dev/null +++ b/core/migrations/0052_preferences_show_my_timetable.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-09-24 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("core", "0051_user_cgu_approved_at")] + + operations = [ + migrations.AddField( + model_name="preferences", + name="show_my_timetable", + field=models.BooleanField( + default=True, + help_text=( + "Allow subscribers to see your timetable and " + "to compare it to theirs (if you have filled it)" + ), + verbose_name="show your timetable to others", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 8b057a03..7baec8d1 100644 --- a/core/models.py +++ b/core/models.py @@ -799,6 +799,14 @@ class Preferences(models.Model): ), default=False, ) + show_my_timetable = models.BooleanField( + _("show your timetable to others"), + help_text=_( + "Allow subscribers to see your timetable and " + "to compare it to theirs (if you have filled it)" + ), + default=True, + ) notify_on_click = models.BooleanField( _("get a notification for every click"), default=False ) diff --git a/timetable/admin.py b/timetable/admin.py index 846f6b40..772f47d8 100644 --- a/timetable/admin.py +++ b/timetable/admin.py @@ -1 +1,17 @@ # Register your models here. +from django.contrib import admin + +from timetable.models import Timetable, TimetableSlot + + +class TimetableSlotInline(admin.TabularInline): + model = TimetableSlot + + +@admin.register(Timetable) +class TimetableAdmin(admin.ModelAdmin): + list_display = ("user", "semester") + search_fields = ("user__nick_name", "user__first_name", "user__last_name") + autocomplete_fields = ("user",) + inlines = (TimetableSlotInline,) + list_select_related = ("user",) diff --git a/timetable/migrations/0001_initial.py b/timetable/migrations/0001_initial.py new file mode 100644 index 00000000..a1edeb5a --- /dev/null +++ b/timetable/migrations/0001_initial.py @@ -0,0 +1,141 @@ +# Generated by Django 5.2.17 on 2026-10-03 21:43 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import core.utils + + +class Migration(migrations.Migration): + initial = True + + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + + operations = [ + migrations.CreateModel( + name="Timetable", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "semester", + models.CharField( + blank=True, + default=core.utils.get_semester_code, + validators=[ + django.core.validators.RegexValidator("(A|P)\\d{2}") + ], + verbose_name="semester", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="timetables", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "timetable", + "verbose_name_plural": "timetables", + "permissions": [("add_self_timetable", "Can add its own timetable")], + }, + ), + migrations.CreateModel( + name="TimetableSlot", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "weekday", + models.SmallIntegerField( + choices=[ + (1, "Monday"), + (2, "Tuesday"), + (3, "Wednesday"), + (4, "Thursday"), + (5, "Friday"), + (6, "Saturday"), + (7, "Sunday"), + ], + verbose_name="weekday", + ), + ), + ( + "start_at", + models.PositiveSmallIntegerField( + validators=[django.core.validators.MaxValueValidator(95)], + verbose_name="start at", + ), + ), + ( + "end_at", + models.PositiveSmallIntegerField( + validators=[django.core.validators.MaxValueValidator(95)], + verbose_name="end at", + ), + ), + ( + "course_type", + models.CharField(max_length=12, verbose_name="course type"), + ), + ("room", models.CharField(max_length=32, verbose_name="room")), + ("ue", models.CharField(max_length=32, verbose_name="ue")), + ( + "week_group", + models.CharField( + blank=True, + choices=[("A", "A"), ("B", "B")], + default="", + max_length=1, + verbose_name="week group", + ), + ), + ( + "timetable", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="slots", + to="timetable.timetable", + verbose_name="timetable", + ), + ), + ], + options={ + "verbose_name": "timetable slot", + "verbose_name_plural": "timetable slots", + }, + ), + migrations.AddConstraint( + model_name="timetable", + constraint=models.UniqueConstraint( + fields=("user", "semester"), name="timetable_unique_user_semester" + ), + ), + migrations.AddConstraint( + model_name="timetableslot", + constraint=models.CheckConstraint( + condition=models.Q(("end_at__gt", models.F("start_at"))), + name="timetable_slot_end_after_start", + ), + ), + ] diff --git a/timetable/models.py b/timetable/models.py index 6b202199..e20fb218 100644 --- a/timetable/models.py +++ b/timetable/models.py @@ -1 +1,102 @@ # Create your models here. +from datetime import timedelta + +from django.core.validators import MaxValueValidator, RegexValidator +from django.db import models +from django.db.models import F, Q +from django.utils.translation import gettext_lazy as _ + +from core.models import User +from core.utils import get_semester_code + + +class Timetable(models.Model): + user = models.ForeignKey( + User, + verbose_name=_("user"), + related_name="timetables", + on_delete=models.CASCADE, + ) + semester = models.CharField( + _("semester"), + default=get_semester_code, + validators=[RegexValidator(r"(A|P)\d{2}")], + blank=True, + ) + + class Meta: + verbose_name = _("timetable") + verbose_name_plural = _("timetables") + constraints = [ + models.UniqueConstraint( + fields=("user", "semester"), name="timetable_unique_user_semester" + ) + ] + permissions = [("add_self_timetable", "Can add its own timetable")] + + def __str__(self) -> str: + return f"{self.user} {self.semester}" + + +class TimetableSlot(models.Model): + MINUTES_PER_SLOT = 15 + + class WeekDay(models.IntegerChoices): + MONDAY = 1 + TUESDAY = 2 + WEDNESDAY = 3 + THURSDAY = 4 + FRIDAY = 5 + SATURDAY = 6 + SUNDAY = 7 + + class WeekGroup(models.TextChoices): + A = "A" + B = "B" + BOTH = "" + + timetable = models.ForeignKey( + Timetable, + verbose_name=_("timetable"), + related_name="slots", + on_delete=models.CASCADE, + ) + weekday = models.SmallIntegerField(_("weekday"), choices=WeekDay) + # there are 4 * 24 = 96 quarters in a day, and we start counting at 0 + start_at = models.PositiveSmallIntegerField( + _("start at"), validators=[MaxValueValidator(95)] + ) + end_at = models.PositiveSmallIntegerField( + _("end at"), validators=[MaxValueValidator(95)] + ) + course_type = models.CharField(_("course type"), max_length=12) + room = models.CharField(_("room"), max_length=32) + ue = models.CharField(_("ue"), max_length=32) + week_group = models.CharField( + _("week group"), + max_length=1, + choices=WeekGroup, + default=WeekGroup.BOTH, + blank=True, + ) + + class Meta: + verbose_name = _("timetable slot") + verbose_name_plural = _("timetable slots") + constraints = [ + models.CheckConstraint( + condition=Q(end_at__gt=F("start_at")), + name="timetable_slot_end_after_start", + ) + ] + + def __str__(self) -> str: + return f"{self.start_hour} - {self.end_hour} - {self.get_weekday_display()}" + + @property + def start_hour(self): + return timedelta(minutes=self.start_at * self.MINUTES_PER_SLOT) + + @property + def end_hour(self): + return timedelta(minutes=self.end_at * self.MINUTES_PER_SLOT) From 816cbe3a2cf8fbd1adb53517841c6f0b10b45f3a Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 24 Sep 2026 23:09:46 +0200 Subject: [PATCH 2/9] api to save timetable --- timetable/api.py | 37 +++++++++ timetable/schemas.py | 35 +++++++++ .../bundled/timetable/generator-index.ts | 78 ++++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 timetable/api.py create mode 100644 timetable/schemas.py 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 { From 73ebe9c6742f0735427611548d5f314ef3571756 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 29 Sep 2026 15:35:28 +0200 Subject: [PATCH 3/9] add timetable visibility to UserVisibilityForm --- core/templates/core/fragment/user_visibility.jinja | 8 ++++++++ core/views/forms.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core/templates/core/fragment/user_visibility.jinja b/core/templates/core/fragment/user_visibility.jinja index 82e4f0ea..370665e5 100644 --- a/core/templates/core/fragment/user_visibility.jinja +++ b/core/templates/core/fragment/user_visibility.jinja @@ -29,5 +29,13 @@ {{ form.show_my_stats.errors }} +
+ {{ form.show_my_timetable }} + {{ form.show_my_timetable.label_tag() }} + + {{ form.show_my_timetable.help_text }} + + {{ form.show_my_timetable.errors }} +
\ No newline at end of file diff --git a/core/views/forms.py b/core/views/forms.py index c0510c7d..eb9dce95 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm): __preferences_fields = forms.fields_for_model( Preferences, - ["show_my_stats"], - widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})}, + ["show_my_stats", "show_my_timetable"], + widgets={ + "show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}), + "show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}) + }, ) show_my_stats = __preferences_fields["show_my_stats"] + show_my_timetable = __preferences_fields["show_my_timetable"] def __init__( self, *args, initial: dict | None = None, instance: User | None = None, **kwargs @@ -347,12 +351,14 @@ class UserVisibilityForm(forms.ModelForm): if instance: initial = initial or {} initial["show_my_stats"] = instance.preferences.show_my_stats + initial["show_my_timetable"] = instance.preferences.show_my_timetable super().__init__(*args, initial=initial, instance=instance, **kwargs) def save(self, commit=True) -> User: # noqa: FBT002 instance = super().save(commit=commit) if commit: instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"] + instance.preferences.show_my_timetable = self.cleaned_data["show_my_timetable"] instance.preferences.save() return instance From 3f135777cdf112295804c59efd85913c148bca94 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 30 Sep 2026 17:01:29 +0200 Subject: [PATCH 4/9] add `show_my_timetable` to VisibilityForm --- core/views/forms.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/views/forms.py b/core/views/forms.py index eb9dce95..cc235a2e 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -339,7 +339,7 @@ class UserVisibilityForm(forms.ModelForm): ["show_my_stats", "show_my_timetable"], widgets={ "show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}), - "show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}) + "show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}), }, ) show_my_stats = __preferences_fields["show_my_stats"] @@ -358,7 +358,9 @@ class UserVisibilityForm(forms.ModelForm): instance = super().save(commit=commit) if commit: instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"] - instance.preferences.show_my_timetable = self.cleaned_data["show_my_timetable"] + instance.preferences.show_my_timetable = self.cleaned_data[ + "show_my_timetable" + ] instance.preferences.save() return instance From 06578880769c411a8c76dbce10603c42f01297f7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 30 Sep 2026 17:02:02 +0200 Subject: [PATCH 5/9] User timetable save --- .../bundled/timetable/generator-index.ts | 39 ++++++++++++++++-- timetable/templates/timetable/generator.jinja | 41 +++++++++++++++---- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/timetable/static/bundled/timetable/generator-index.ts b/timetable/static/bundled/timetable/generator-index.ts index 54c48e07..3ead147a 100644 --- a/timetable/static/bundled/timetable/generator-index.ts +++ b/timetable/static/bundled/timetable/generator-index.ts @@ -154,10 +154,10 @@ document.addEventListener("alpine:init", () => { courses: [] as TimetableSlot[], startSlot: 0, endSlot: 0, - table: { - height: 0, - width: 0, - }, + table: { height: 0, width: 0 }, + edtPublic: false, + saving: false, + timetableSavedMessage: new AlertMessage(), colors: {} as Record, colorPalette: [ @@ -252,5 +252,36 @@ document.addEventListener("alpine:init", () => { 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) => + Object.assign({}, 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, + }), + ), + }, + }); + 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; + }, })); }); diff --git a/timetable/templates/timetable/generator.jinja b/timetable/templates/timetable/generator.jinja index 27bd83a2..0769181f 100644 --- a/timetable/templates/timetable/generator.jinja +++ b/timetable/templates/timetable/generator.jinja @@ -92,12 +92,39 @@ - + {% endblock content %} From ed888834d65a6f54c4b293b3e9b80a9ec957400a Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 3 Oct 2026 23:21:32 +0200 Subject: [PATCH 6/9] split timetable parsing and display --- ...{generator-index.ts => timetable-index.ts} | 85 +++++-------------- timetable/static/timetable/css/generator.scss | 2 +- timetable/templates/timetable/generator.jinja | 43 ++-------- timetable/templates/timetable/macros.jinja | 46 ++++++++++ 4 files changed, 76 insertions(+), 100 deletions(-) rename timetable/static/bundled/timetable/{generator-index.ts => timetable-index.ts} (77%) create mode 100644 timetable/templates/timetable/macros.jinja diff --git a/timetable/static/bundled/timetable/generator-index.ts b/timetable/static/bundled/timetable/timetable-index.ts similarity index 77% rename from timetable/static/bundled/timetable/generator-index.ts rename to timetable/static/bundled/timetable/timetable-index.ts index 3ead147a..a121a48a 100644 --- a/timetable/static/bundled/timetable/generator-index.ts +++ b/timetable/static/bundled/timetable/timetable-index.ts @@ -78,7 +78,10 @@ function parseSlots(s: string): TimetableSlot[] { } document.addEventListener("alpine:init", () => { - Alpine.data("timetableGenerator", () => ({ + /** + * Alpine data that can parse a timetable, save it and export it to png. + */ + Alpine.data("timetableGenerator", (userId: number) => ({ content: DEFAULT_TIMETABLE, error: null as { incorrectRow?: string } | null, courses: [] as TimetableSlot[], @@ -111,6 +114,8 @@ document.addEventListener("alpine:init", () => { async saveSith() { this.saving = true; const res = await timetableSaveTimetable({ + // biome-ignore lint/style/useNamingConvention: api is snake case + path: { user_id: userId }, body: { // biome-ignore lint/style/useNamingConvention: api is snake case is_viewable: this.edtPublic, @@ -151,13 +156,10 @@ document.addEventListener("alpine:init", () => { */ Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({ displayedWeekdays: [] as WeekDay[], - courses: [] as TimetableSlot[], startSlot: 0, endSlot: 0, table: { height: 0, width: 0 }, - edtPublic: false, - saving: false, - timetableSavedMessage: new AlertMessage(), + slots: slots, colors: {} as Record, colorPalette: [ @@ -174,19 +176,14 @@ document.addEventListener("alpine:init", () => { "#1b5e20", ], - 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; - } + init() { + this.$watch("slots", () => this.display()); + }, + display() { // color each UE let colorIndex = 0; - for (const slot of this.courses) { + for (const slot of this.slots) { if (!this.colors[slot.ueCode]) { this.colors[slot.ueCode] = this.colorPalette[colorIndex % this.colorPalette.length]; @@ -195,13 +192,13 @@ document.addEventListener("alpine:init", () => { } this.displayedWeekdays = WEEKDAYS.filter((day) => - this.courses.some((slot: TimetableSlot) => slot.weekday === day), + this.slots.some((slot: TimetableSlot) => slot.weekday === day), ); - this.startSlot = this.courses.reduce( + this.startSlot = this.slots.reduce( (acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot), 25 * 4, ); - this.endSlot = this.courses.reduce( + this.endSlot = this.slots.reduce( (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot), 1, ); @@ -222,15 +219,19 @@ document.addEventListener("alpine:init", () => { }; }, - getHours(): [string, object][] { + /** + * Return the hours that should be displayed on this timetable, + * with their `top` placing + */ + getHours(): [string, { top: `${number}px` }][] { let hour: number = Number.parseInt( - this.courses + this.slots .map((c: TimetableSlot) => c.startHour) .reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00") .split(":")[0], 10, ); - const res: [string, object][] = []; + const res: [string, { top: `${number}px` }][] = []; for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) { res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]); hour += 1; @@ -241,47 +242,5 @@ document.addEventListener("alpine:init", () => { getWidth() { return this.displayedWeekdays.length * SLOT_WIDTH + 20; }, - - 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) => - Object.assign({}, 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, - }), - ), - }, - }); - 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; - }, })); }); diff --git a/timetable/static/timetable/css/generator.scss b/timetable/static/timetable/css/generator.scss index 758892de..095ed30e 100644 --- a/timetable/static/timetable/css/generator.scss +++ b/timetable/static/timetable/css/generator.scss @@ -1,6 +1,6 @@ @import "core/static/core/colors"; -#timetable { +.timetable { --hour-side-width: 60px; display: block; diff --git a/timetable/templates/timetable/generator.jinja b/timetable/templates/timetable/generator.jinja index 0769181f..7c2964f6 100644 --- a/timetable/templates/timetable/generator.jinja +++ b/timetable/templates/timetable/generator.jinja @@ -1,11 +1,13 @@ -{% extends 'core/base.jinja' %} +{% extends "core/base.jinja" %} + +{% from "timetable/macros.jinja" import display_timetable %} {%- block additional_css -%} {%- endblock -%} {%- block additional_js -%} - + {%- endblock -%} {% block title %} @@ -32,7 +34,7 @@ {% endblock %} {% block content %} -
+

{% trans %}Timetable generator{% endtrans %}