diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index cc423ccc..b53ff784 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -83,7 +83,8 @@ #links_content { overflow: auto; box-shadow: $shadow-color 1px 1px 1px; - height: 20em; + min-height: 20em; + padding-bottom: 1em; h4 { margin-left: 5px; diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 1becd1ba..9a462607 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -205,6 +205,10 @@ {% trans %}UV Guide{% endtrans %} +
  • + + {% trans %}Timetable{% endtrans %} +
  • {% trans %}Matmatronch{% endtrans %} diff --git a/core/models.py b/core/models.py index e467c8ad..0506364a 100644 --- a/core/models.py +++ b/core/models.py @@ -651,9 +651,6 @@ class User(AbstractUser): class AnonymousUser(AuthAnonymousUser): - def __init__(self): - super().__init__() - @property def was_subscribed(self): return False @@ -662,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser): def is_subscribed(self): return False - @property - def subscribed(self): - return False - @property def is_root(self): return False diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index aa135d87..2ce8cde2 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -1061,6 +1061,10 @@ msgstr "Nos services" msgid "UV Guide" msgstr "Guide des UVs" +#: com/templates/com/news_list.jinja +msgid "Timetable" +msgstr "Emploi du temps" + #: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja msgid "Matmatronch" msgstr "Matmatronch" @@ -5236,6 +5240,18 @@ msgstr "Membre existant" msgid "the groups that can create subscriptions" msgstr "les groupes pouvant créer des cotisations" +#: timetable/templates/timetable/generator.jinja +msgid "Timetable generator" +msgstr "Générateur d'emploi du temps" + +#: timetable/templates/timetable/generator.jinja +msgid "Generate" +msgstr "Générer" + +#: timetable/templates/timetable/generator.jinja +msgid "Save to PNG" +msgstr "Sauver en PNG" + #: trombi/models.py msgid "subscription deadline" msgstr "fin des inscriptions" diff --git a/package-lock.json b/package-lock.json index 74df5d10..8447dc00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "d3-force-3d": "^3.0.5", "easymde": "^2.19.0", "glob": "^11.0.0", + "html2canvas": "^1.4.1", "htmx.org": "^2.0.3", "js-cookie": "^3.0.5", "lit-html": "^3.3.0", @@ -3105,6 +3106,15 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3493,6 +3503,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cytoscape": { "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", @@ -4165,6 +4184,19 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmx.org": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", @@ -5454,6 +5486,15 @@ "dev": true, "license": "ISC" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/three": { "version": "0.177.0", "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", @@ -5711,6 +5752,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", diff --git a/package.json b/package.json index fedc2f8e..d484f144 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "d3-force-3d": "^3.0.5", "easymde": "^2.19.0", "glob": "^11.0.0", + "html2canvas": "^1.4.1", "htmx.org": "^2.0.3", "js-cookie": "^3.0.5", "lit-html": "^3.3.0", diff --git a/sith/settings.py b/sith/settings.py index 68e75173..b502dc56 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -125,6 +125,7 @@ INSTALLED_APPS = ( "pedagogy", "galaxy", "antispam", + "timetable", "api", ) diff --git a/sith/urls.py b/sith/urls.py index 561de616..e6629373 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -53,6 +53,7 @@ urlpatterns = [ path("i18n/", include("django.conf.urls.i18n")), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("captcha/", include("captcha.urls")), + path("edt/", include(("timetable.urls", "timetable"), namespace="timetable")), ] if settings.DEBUG: diff --git a/timetable/__init__.py b/timetable/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timetable/admin.py b/timetable/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/timetable/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/timetable/apps.py b/timetable/apps.py new file mode 100644 index 00000000..b39d6c1e --- /dev/null +++ b/timetable/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TimetableConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "timetable" diff --git a/timetable/migrations/__init__.py b/timetable/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timetable/models.py b/timetable/models.py new file mode 100644 index 00000000..6b202199 --- /dev/null +++ b/timetable/models.py @@ -0,0 +1 @@ +# Create your models here. diff --git a/timetable/static/bundled/timetable/generator-index.ts b/timetable/static/bundled/timetable/generator-index.ts new file mode 100644 index 00000000..826e0400 --- /dev/null +++ b/timetable/static/bundled/timetable/generator-index.ts @@ -0,0 +1,184 @@ +import html2canvas from "html2canvas"; + +// see https://regex101.com/r/QHSaPM/2 +const TIMETABLE_ROW_RE: RegExp = + /^(?\w.+\w)\s+(?[A-Z]{2}\d)\s+((?[AB])\s+)?(?(lundi)|(mardi)|(mercredi)|(jeudi)|(vendredi)|(samedi)|(dimanche))\s+(?\d{2}:\d{2})\s+(?\d{2}:\d{2})\s+[\dA-B]\s+((?[\wé]*)\s+)?(?\w+(?:, \w+)?)$/; + +const DEFAULT_TIMETABLE: string = `DS52\t\tCM1\t\tlundi\t08:00\t10:00\t1\tPrésentiel\tA113 +DS53\t\tCM1\t\tlundi\t10:15\t12:15\t1\tPrésentiel\tA101 +DS53\t\tTP1\t\tlundi\t13:00\t16:00\t1\tPrésentiel\tH010 +SO03\t\tCM1\t\tlundi\t16:15\t17:45\t1\tPrésentiel\tA103 +SO03\t\tTD1\t\tlundi\t17:45\t19:45\t1\tPrésentiel\tA103 +DS50\t\tTP1\t\tmardi\t08:00\t10:00\t1\tPrésentiel\tA216 +DS51\t\tCM1\t\tmardi\t10:15\t12:15\t1\tPrésentiel\tA216 +DS51\t\tTP1\t\tmardi\t14:00\t18:00\t1\tPrésentiel\tH010 +DS52\t\tTP2\tA\tjeudi\t08:00\t10:00\tA\tPrésentiel\tA110a, A110b +DS52\t\tTD1\t\tjeudi\t10:15\t12:15\t1\tPrésentiel\tA110a, A110b +LC02\t\tTP1\t\tjeudi\t15:00\t16:00\t1\tPrésentiel\tA209 +LC02\t\tTD1\t\tjeudi\t16:15\t18:15\t1\tPrésentiel\tA206`; + +type WeekDay = + | "lundi" + | "mardi" + | "mercredi" + | "jeudi" + | "vendredi" + | "samedi" + | "dimanche"; + +const WEEKDAYS = [ + "lundi", + "mardi", + "mercredi", + "jeudi", + "vendredi", + "samedi", + "dimanche", +] as const; + +const SLOT_HEIGHT = 20 as const; // Each 15min has a height of 20px in the timetable +const SLOT_WIDTH = 250 as const; // Each weekday ha a width of 400px in the timetable +const MINUTES_PER_SLOT = 15 as const; + +interface TimetableSlot { + courseType: string; + room: string; + startHour: string; + endHour: string; + startSlot: number; + endSlot: number; + ueCode: string; + weekGroup?: string; + weekday: WeekDay; +} + +function parseSlots(s: string): TimetableSlot[] { + return s + .split("\n") + .filter((s: string) => s.length > 0) + .map((row: string) => { + const parsed = TIMETABLE_ROW_RE.exec(row); + if (!parsed) { + throw new Error(`Couldn't parse row ${row}`); + } + const [startHour, startMin] = parsed.groups.startHour + .split(":") + .map((i) => Number.parseInt(i)); + const [endHour, endMin] = parsed.groups.endHour + .split(":") + .map((i) => Number.parseInt(i)); + return { + ...parsed.groups, + startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT), + endSlot: Math.floor((endHour * 60 + endMin) / MINUTES_PER_SLOT), + } as unknown as TimetableSlot; + }); +} + +document.addEventListener("alpine:init", () => { + Alpine.data("timetableGenerator", () => ({ + content: DEFAULT_TIMETABLE, + error: "", + displayedWeekdays: [] as WeekDay[], + courses: [] as TimetableSlot[], + startSlot: 0, + endSlot: 0, + table: { + height: 0, + width: 0, + }, + + colors: {} as Record, + colorPalette: [ + "#27ae60", + "#2980b9", + "#c0392b", + "#7f8c8d", + "#f1c40f", + "#1abc9c", + "#95a5a6", + "#26C6DA", + "#c2185b", + "#e64a19", + "#1b5e20", + ], + + generate() { + try { + this.courses = parseSlots(this.content); + } catch { + this.error = gettext( + "Wrong timetable format. Make sure you copied if from your student folder.", + ); + return; + } + + // color each UE + let colorIndex = 0; + for (const slot of this.courses) { + if (!this.colors[slot.ueCode]) { + this.colors[slot.ueCode] = + this.colorPalette[colorIndex % this.colorPalette.length]; + colorIndex++; + } + } + + this.displayedWeekdays = WEEKDAYS.filter((day) => + this.courses.some((slot: TimetableSlot) => slot.weekday === day), + ); + this.startSlot = this.courses.reduce( + (acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot), + 25 * 4, + ); + this.endSlot = this.courses.reduce( + (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot), + 1, + ); + this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot); + this.table.width = SLOT_WIDTH * this.displayedWeekdays.length; + }, + + getStyle(slot: TimetableSlot) { + const hasWeekGroup = slot.weekGroup !== undefined; + const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH; + const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0; + return { + height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`, + width: `${width}px`, + top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`, + left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH + leftOffset}px`, + backgroundColor: this.colors[slot.ueCode], + }; + }, + + getHours(): [string, object][] { + let hour: number = Number.parseInt( + this.courses + .map((c: TimetableSlot) => c.startHour) + .reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00") + .split(":")[0], + ); + const res: [string, object][] = []; + 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; + } + return res; + }, + + getWidth() { + return this.displayedWeekdays.length * SLOT_WIDTH + 20; + }, + + async savePng() { + const elem = document.getElementById("timetable"); + const img = (await html2canvas(elem)).toDataURL(); + const downloadLink = document.createElement("a"); + downloadLink.href = img; + downloadLink.download = "edt.png"; + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + }, + })); +}); diff --git a/timetable/static/timetable/css/generator.scss b/timetable/static/timetable/css/generator.scss new file mode 100644 index 00000000..758892de --- /dev/null +++ b/timetable/static/timetable/css/generator.scss @@ -0,0 +1,67 @@ +@import "core/static/core/colors"; + +#timetable { + --hour-side-width: 60px; + + display: block; + margin: 2em auto; + .header { + background-color: $white-color; + font-weight: bold; + box-shadow: none; + width: calc(100% - var(--hour-side-width) - 10px); + margin-left: var(--hour-side-width); + padding-left: 0; + display: flex; + flex-direction: row; + gap: 0; + span { + flex: 1; + text-align: center; + } + } + .content { + position: relative; + } + .hours { + position: absolute; + width: 40px; + left: 0; + top: -.5em; + + .hour { + position: absolute; + + .hour-bar { + content: ""; + position: absolute; + height: 1px; + background: lightgray; + top: 50%; + left: 100%; + margin-left: 10px; + } + } + } + .courses { + position: absolute; + text-align: center; + top: 0; + left: var(--hour-side-width); + + .slot { + background-color: cadetblue; + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + + .course-type { + position: absolute; + top: 0; + right: 0; + padding: 10px; + } + } + } +} diff --git a/timetable/templates/timetable/generator.jinja b/timetable/templates/timetable/generator.jinja new file mode 100644 index 00000000..bbbc52b7 --- /dev/null +++ b/timetable/templates/timetable/generator.jinja @@ -0,0 +1,68 @@ +{% extends 'core/base.jinja' %} + +{%- block additional_css -%} + +{%- endblock -%} + +{%- block additional_js -%} + +{%- endblock -%} + +{% block title %} + {% trans %}Timetable generator{% endtrans %} +{% endblock %} + +{% block content %} +
    +
    +

    Générateur d'emploi du temps

    +
    + +
    +
    + + +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +{% endblock content %} diff --git a/timetable/tests.py b/timetable/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/timetable/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/timetable/urls.py b/timetable/urls.py new file mode 100644 index 00000000..e079490c --- /dev/null +++ b/timetable/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from timetable.views import GeneratorView + +urlpatterns = [path("", GeneratorView.as_view(), name="generator")] diff --git a/timetable/views.py b/timetable/views.py new file mode 100644 index 00000000..01b1197e --- /dev/null +++ b/timetable/views.py @@ -0,0 +1,8 @@ +# Create your views here. +from django.views.generic import TemplateView + +from core.auth.mixins import FormerSubscriberMixin + + +class GeneratorView(FormerSubscriberMixin, TemplateView): + template_name = "timetable/generator.jinja"