8 Commits

Author SHA1 Message Date
imperosol
57888359ef add translations 2025-09-13 00:37:44 +02:00
imperosol
535b493b03 display course type on top left of slots 2025-09-13 00:19:18 +02:00
imperosol
61eef6bbca add timetable to common links 2025-09-13 00:18:25 +02:00
imperosol
b902325083 simplify timetable generator url 2025-09-13 00:18:25 +02:00
Kenneth SOARES
05b55bc540 update regex 2025-09-13 00:18:25 +02:00
Kenneth SOARES
ddb1f7270a add colors to each subject 2025-09-13 00:18:25 +02:00
imperosol
306b71dad5 allow export to Png 2025-09-09 12:44:56 +02:00
imperosol
38fb995f24 timetable base 2025-09-09 12:44:56 +02:00
22 changed files with 393 additions and 59 deletions

View File

@@ -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;

View File

@@ -205,6 +205,10 @@
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-calendar-days fa-xl"></i>
<a href="{{ url("timetable:generator") }}">{% trans %}Timetable{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>

View File

@@ -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

View File

@@ -45,9 +45,8 @@ class Command(BaseCommand):
"verbosity level should be between 0 and 2 included", stacklevel=2
)
if options["verbosity"] >= 2:
if options["verbosity"] == 2:
logger.setLevel(logging.DEBUG)
logging.getLogger("django.db.backends").setLevel(logging.DEBUG)
elif options["verbosity"] == 1:
logger.setLevel(logging.INFO)
else:
@@ -60,3 +59,6 @@ class Command(BaseCommand):
Galaxy.objects.filter(state__isnull=True).delete()
logger.info("Ruled the galaxy in {} queries.".format(len(connection.queries)))
if options["verbosity"] > 2:
for q in connection.queries:
logger.debug(q)

View File

@@ -31,14 +31,13 @@ from collections import defaultdict
from typing import NamedTuple, TypedDict
from django.db import models
from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet
from django.utils.timezone import localdate, now
from django.db.models import Count, F, Q, QuerySet
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from club.models import Membership
from core.models import User
from sas.models import PeoplePictureRelation, Picture
from subscription.models import Subscription
class GalaxyStar(models.Model):
@@ -199,16 +198,8 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.filter(is_subscriber_viewable=True)
.exclude(subscriptions=None)
.annotate(
pictures_count=Count("pictures"),
is_active_in_galaxy=Exists(
Subscription.objects.filter(
member=OuterRef("id"), subscription_end__gt=now()
)
),
)
User.objects.exclude(subscriptions=None)
.annotate(pictures_count=Count("pictures"))
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
@@ -299,9 +290,9 @@ class Galaxy(models.Model):
31/12/2022 (also two years, but with an offset of one year), then their
club score is 365.
"""
memberships = user.memberships.values("start_date", "end_date", "club_id")
memberships = user.memberships.only("start_date", "end_date", "club_id")
result = defaultdict(int)
today = localdate()
now = localdate()
for membership in memberships:
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
@@ -309,23 +300,23 @@ class Galaxy(models.Model):
Membership.objects.exclude(user=user)
.filter(
Q( # start2 <= start1 <= end2
start_date__lte=membership["start_date"],
end_date__gte=membership["start_date"],
start_date__lte=membership.start_date,
end_date__gte=membership.start_date,
)
| Q( # start2 <= start1 <= today
start_date__lte=membership["start_date"], end_date=None
| Q( # start2 <= start1 <= now
start_date__lte=membership.start_date, end_date=None
)
| Q( # start1 <= start2 <= end2
start_date__gte=membership["start_date"],
start_date__lte=membership["end_date"] or today,
start_date__gte=membership.start_date,
start_date__lte=membership.end_date or now,
),
club_id=membership["club_id"],
club_id=membership.club_id,
)
.only("start_date", "end_date", "user_id")
)
for other in common_memberships:
start = max(membership["start_date"], other.start_date)
end = min(membership["end_date"] or today, other.end_date or today)
start = max(membership.start_date, other.start_date)
end = min(membership.end_date or now, other.end_date or now)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result
@@ -391,22 +382,18 @@ class Galaxy(models.Model):
# this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient
rulable_users_qs = self.get_rulable_users(picture_count_threshold)
active_users_count = rulable_users_qs.filter(is_active_in_galaxy=True).count()
rulable_users = list(rulable_users_qs)
rulable_users = list(self.get_rulable_users(picture_count_threshold))
rulable_users_count = len(rulable_users)
user1_count = 0
self.logger.info(
f" {len(rulable_users)} citizens (with {active_users_count} active ones) "
f"have been listed. Starting to rule."
f"{rulable_users_count} citizen have been listed. Starting to rule."
)
self.logger.info("Creating stars for all citizen")
individual_scores = self.compute_individual_scores()
GalaxyStar.objects.bulk_create(
[
GalaxyStar(
owner_id=user.id, galaxy=self, mass=individual_scores[user.id]
)
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
for user in rulable_users
]
)
@@ -418,9 +405,9 @@ class Galaxy(models.Model):
t_global_start = time.time()
while len(rulable_users) > 0:
user1 = rulable_users.pop()
if not user1.is_active_in_galaxy:
continue
user1_count += 1
rulable_users_count2 = len(rulable_users)
star1 = stars[user1.id]
lanes = []
@@ -461,20 +448,17 @@ class Galaxy(models.Model):
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info(
f"Progression: {user1_count}/{active_users_count} "
f"citizen -- {active_users_count - user1_count} remaining"
f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {rulable_users_count - user1_count} remaining"
)
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
eta = len(rulable_users) // global_avg_speed
eta = rulable_users_count2 // global_avg_speed
self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
)
self.logger.info("#" * 60)
t_global_start = time.time()
count, _ = self.stars.filter(Q(lanes1=None) & Q(lanes2=None)).delete()
self.logger.info(f"{count} orphan stars have been trimmed.")
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
# should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.
old_galaxies_pks = list(

View File

@@ -122,7 +122,7 @@ class TestGalaxyModel(TestCase):
self.com,
]
with self.assertNumQueries(38):
with self.assertNumQueries(44):
while len(users) > 0:
user1 = users.pop(0)
family_scores = Galaxy.compute_user_family_score(user1)
@@ -150,7 +150,7 @@ class TestGalaxyModel(TestCase):
that the number of queries to rule the galaxy is stable.
"""
galaxy = Galaxy.objects.create()
with self.assertNumQueries(36):
with self.assertNumQueries(39):
galaxy.rule(0) # We want everybody here

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n"
"POT-Creation-Date: 2025-09-13 00:17+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -1043,6 +1043,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"
@@ -1713,8 +1717,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities."
msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts"
@@ -2157,10 +2161,6 @@ msgstr ""
msgid "Page history"
msgstr "Historique de la page"
#: core/templates/core/page_list.jinja
msgid "There is no page in this website."
msgstr "Il n'y a pas de page sur ce site web."
#: core/templates/core/page_prop.jinja
msgid "Page properties"
msgstr "Propriétés de la page"
@@ -5201,6 +5201,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"
@@ -5498,3 +5510,9 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "Timeplan generator"
#~ msgstr "Temps de génération du template : "
#~ msgid "There is no page in this website."
#~ msgstr "Il n'y a pas de page sur ce site web."

50
package-lock.json generated
View File

@@ -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",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
@@ -3124,6 +3125,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",
@@ -3512,6 +3522,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",
@@ -4184,6 +4203,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",
@@ -5479,6 +5511,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",
@@ -5736,6 +5777,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.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@@ -60,6 +60,7 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",

View File

@@ -125,6 +125,7 @@ INSTALLED_APPS = (
"pedagogy",
"galaxy",
"antispam",
"timetable",
"api",
)

View File

@@ -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:

0
timetable/__init__.py Normal file
View File

1
timetable/admin.py Normal file
View File

@@ -0,0 +1 @@
# Register your models here.

6
timetable/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TimetableConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "timetable"

View File

1
timetable/models.py Normal file
View File

@@ -0,0 +1 @@
# Create your models here.

View File

@@ -0,0 +1,164 @@
import html2canvas from "html2canvas";
// see https://regex101.com/r/QHSaPM/2
const TIMETABLE_ROW_RE: RegExp =
/^(?<ueCode>\w.+\w)\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+((?<attendance>[\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 = 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,
table: {
height: 0,
width: 0,
},
colors: {} as Record<string, string>,
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],
};
},
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();
},
}));
});

View File

@@ -0,0 +1,35 @@
#timetable {
display: block;
margin: 2em auto ;
.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;
.course-type {
position: absolute;
top: 0;
right: 0;
padding: 10px;
}
}
}
}

View File

@@ -0,0 +1,58 @@
{% 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 %}Timetable 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>
<span class="alert-main" x-text="error"></span>
</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+40}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 class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
</button>
</div>
{% endblock content %}

1
timetable/tests.py Normal file
View File

@@ -0,0 +1 @@
# Create your tests here.

5
timetable/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path
from timetable.views import GeneratorView
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]

8
timetable/views.py Normal file
View File

@@ -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"