1 Commits

Author SHA1 Message Date
imperosol
32f63fb57f include only subscribed users and their direct relations in the galaxy 2025-09-11 19:49:04 +02:00
22 changed files with 59 additions and 393 deletions

View File

@@ -83,8 +83,7 @@
#links_content { #links_content {
overflow: auto; overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em; height: 20em;
padding-bottom: 1em;
h4 { h4 {
margin-left: 5px; margin-left: 5px;

View File

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

View File

@@ -651,6 +651,9 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser): class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property @property
def was_subscribed(self): def was_subscribed(self):
return False return False
@@ -659,6 +662,10 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self): def is_subscribed(self):
return False return False
@property
def subscribed(self):
return False
@property @property
def is_root(self): def is_root(self):
return False return False

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-13 00:17+0200\n" "POT-Creation-Date: 2025-09-01 18:18+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -1043,10 +1043,6 @@ msgstr "Nos services"
msgid "UV Guide" msgid "UV Guide"
msgstr "Guide des UVs" 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 #: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
msgid "Matmatronch" msgid "Matmatronch"
msgstr "Matmatronch" msgstr "Matmatronch"
@@ -1717,8 +1713,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises " "AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities." "student life at UTBM and manages its student facilities."
msgstr "" msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. " "L'AE UTBM est une association bénévole gérée par les étudiants de "
"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." "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 #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -2161,6 +2157,10 @@ msgstr ""
msgid "Page history" msgid "Page history"
msgstr "Historique de la page" 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 #: core/templates/core/page_prop.jinja
msgid "Page properties" msgid "Page properties"
msgstr "Propriétés de la page" msgstr "Propriétés de la page"
@@ -5201,18 +5201,6 @@ msgstr "Membre existant"
msgid "the groups that can create subscriptions" msgid "the groups that can create subscriptions"
msgstr "les groupes pouvant créer des cotisations" 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 #: trombi/models.py
msgid "subscription deadline" msgid "subscription deadline"
msgstr "fin des inscriptions" msgstr "fin des inscriptions"
@@ -5510,9 +5498,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(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,7 +29,6 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -3125,15 +3124,6 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3522,15 +3512,6 @@
"node": ">= 8" "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": { "node_modules/cytoscape": {
"version": "3.33.1", "version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
@@ -4203,19 +4184,6 @@
"node": ">= 0.4" "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": { "node_modules/htmx.org": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
@@ -5511,15 +5479,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/three": {
"version": "0.177.0", "version": "0.177.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz",
@@ -5777,15 +5736,6 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")), path("captcha/", include("captcha.urls")),
path("edt/", include(("timetable.urls", "timetable"), namespace="timetable")),
] ]
if settings.DEBUG: if settings.DEBUG:

View File

View File

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

View File

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

View File

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

View File

@@ -1,164 +0,0 @@
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

@@ -1,35 +0,0 @@
#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

@@ -1,58 +0,0 @@
{% 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 %}

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
# 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"