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 %}
+
+
+
+
+
+{% 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"