From db6c356f73eb3489e226d3862babe9ac1c16953a Mon Sep 17 00:00:00 2001
From: imperosol <thgirod@hotmail.com>
Date: Mon, 3 Mar 2025 15:24:22 +0100
Subject: [PATCH] timetable base

---
 core/models.py                                |   7 -
 sith/settings.py                              |   1 +
 sith/urls.py                                  |   1 +
 timetable/__init__.py                         |   0
 timetable/admin.py                            |   1 +
 timetable/apps.py                             |   6 +
 timetable/migrations/__init__.py              |   0
 timetable/models.py                           |   1 +
 .../bundled/timetable/generator-index.ts      | 121 ++++++++++++++++++
 timetable/static/timetable/css/generator.scss |  26 ++++
 timetable/templates/timetable/generator.jinja |  49 +++++++
 timetable/tests.py                            |   1 +
 timetable/urls.py                             |   5 +
 timetable/views.py                            |  10 ++
 14 files changed, 222 insertions(+), 7 deletions(-)
 create mode 100644 timetable/__init__.py
 create mode 100644 timetable/admin.py
 create mode 100644 timetable/apps.py
 create mode 100644 timetable/migrations/__init__.py
 create mode 100644 timetable/models.py
 create mode 100644 timetable/static/bundled/timetable/generator-index.ts
 create mode 100644 timetable/static/timetable/css/generator.scss
 create mode 100644 timetable/templates/timetable/generator.jinja
 create mode 100644 timetable/tests.py
 create mode 100644 timetable/urls.py
 create mode 100644 timetable/views.py

diff --git a/core/models.py b/core/models.py
index 0c65e043..f97a25a5 100644
--- a/core/models.py
+++ b/core/models.py
@@ -636,9 +636,6 @@ class User(AbstractUser):
 
 
 class AnonymousUser(AuthAnonymousUser):
-    def __init__(self):
-        super().__init__()
-
     @property
     def was_subscribed(self):
         return False
@@ -647,10 +644,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/sith/settings.py b/sith/settings.py
index b095a114..274a88b9 100644
--- a/sith/settings.py
+++ b/sith/settings.py
@@ -124,6 +124,7 @@ INSTALLED_APPS = (
     "pedagogy",
     "galaxy",
     "antispam",
+    "timetable",
     "api",
 )
 
diff --git a/sith/urls.py b/sith/urls.py
index dd560626..59ab7128 100644
--- a/sith/urls.py
+++ b/sith/urls.py
@@ -49,6 +49,7 @@ urlpatterns = [
     path("i18n/", include("django.conf.urls.i18n")),
     path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
     path("captcha/", include("captcha.urls")),
+    path("timetable/", 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..19d716c0
--- /dev/null
+++ b/timetable/static/bundled/timetable/generator-index.ts
@@ -0,0 +1,121 @@
+// see https://regex101.com/r/QHSaPM/2
+const TIMETABLE_ROW_RE: RegExp =
+  /^(?<ueCode>[A-Z\d]{4}(?:\+[A-Z\d]{4})?)\s+(?<courseType>[A-Z]{2}\d)\s+((?<weekGroup>[AB])\s+)?(?<weekday>(lundi)|(mardi)|(mercredi)|(jeudi)|(vendredi)|(samedi)|(dimanche))\s+(?<startHour>\d{2}:\d{2})\s+(?<endHour>\d{2}:\d{2})\s+[\dA-B]\s+(?:[\wé]*\s+)?(?<room>\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 = 400 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,
+    table: {
+      height: 0,
+      width: 0,
+    },
+
+    generate() {
+      try {
+        this.courses = parseSlots(this.content);
+      } catch {
+        this.error = gettext(
+          "Wrong timetable format. Make sure you copied if from your student folder.",
+        );
+        return;
+      }
+      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),
+        24 * 4,
+      );
+      this.endSlot = this.courses.reduce(
+        (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
+        0,
+      );
+      this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot);
+      this.table.width = SLOT_WIDTH * this.displayedWeekdays.length;
+    },
+
+    getStyle(slot: TimetableSlot) {
+      return {
+        height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`,
+        width: `${SLOT_WIDTH}px`,
+        top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`,
+        left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH}px`,
+      };
+    },
+  }));
+});
diff --git a/timetable/static/timetable/css/generator.scss b/timetable/static/timetable/css/generator.scss
new file mode 100644
index 00000000..ae5fb83f
--- /dev/null
+++ b/timetable/static/timetable/css/generator.scss
@@ -0,0 +1,26 @@
+#timetable {
+  .header {
+    background-color: white;
+    box-shadow: none;
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    gap: 0;
+    span {
+      flex: 1;
+      text-align: center;
+    }
+  }
+  .content {
+    position: relative;
+    text-align: center;
+
+    .slot {
+      background-color: cadetblue;
+      position: absolute;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+    }
+  }
+}
diff --git a/timetable/templates/timetable/generator.jinja b/timetable/templates/timetable/generator.jinja
new file mode 100644
index 00000000..81f72707
--- /dev/null
+++ b/timetable/templates/timetable/generator.jinja
@@ -0,0 +1,49 @@
+{% extends 'core/base.jinja' %}
+
+{%- block additional_css -%}
+  <link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
+{%- endblock -%}
+
+{%- block additional_js -%}
+  <script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
+{%- endblock -%}
+
+{% block title %}
+  {% trans %}Timeplan generator{% endtrans %}
+{% endblock %}
+
+{% block content %}
+  <div x-data="timetableGenerator">
+    <form @submit.prevent="generate()">
+      <h1>Générateur d'emploi du temps</h1>
+      <div class="alert alert-red" x-show="!!error" x-cloak>
+        <p class="alert-main" x-text="error"></p>
+      </div>
+      <div class="form-group">
+        <label for="timetable-input">Colle ton emploi du temps (sans l'entête)</label>
+        <textarea id="timetable-input" cols="30" rows="15" x-model="content"></textarea>
+      </div>
+      <input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
+    </form>
+    <div
+      id="timetable"
+      x-show="table.height > 0 && table.width > 0"
+      :style="{width: `${table.width}px`, height: `${table.height}px`}"
+    >
+      <div class="header">
+        <template x-for="weekday in displayedWeekdays">
+          <span x-text="weekday"></span>
+        </template>
+      </div>
+      <div class="content">
+        <template x-for="course in courses">
+          <div class="slot" :style="getStyle(course)">
+            <span x-text="`${course.ueCode} (${course.courseType})`"></span>
+            <span x-text="`${course.startHour} - ${course.endHour}`"></span>
+            <span x-text="course.room"></span>
+          </div>
+        </template>
+      </div>
+    </div>
+  </div>
+{% 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..5e141005
--- /dev/null
+++ b/timetable/urls.py
@@ -0,0 +1,5 @@
+from django.urls import path
+
+from timetable.views import GeneratorView
+
+urlpatterns = [path("generator/", GeneratorView.as_view(), name="generator")]
diff --git a/timetable/views.py b/timetable/views.py
new file mode 100644
index 00000000..351f199a
--- /dev/null
+++ b/timetable/views.py
@@ -0,0 +1,10 @@
+# Create your views here.
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.views.generic import TemplateView
+
+
+class GeneratorView(UserPassesTestMixin, TemplateView):
+    template_name = "timetable/generator.jinja"
+
+    def test_func(self):
+        return self.request.user.is_subscribed