Compare commits

..

1 Commits

Author SHA1 Message Date
NaNoMelo
500af2f73a add condition for EBOUTIC counter type in subscription creation 2025-10-01 14:37:41 +02:00
28 changed files with 82 additions and 507 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

@@ -76,20 +76,18 @@
It will stay hidden for other users until it has been published. It will stay hidden for other users until it has been published.
{% endtrans %} {% endtrans %}
</p> </p>
{%- if user.has_perm("com.moderate_news") -%} {% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news, {# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time but it will be executed only for admin users, and only one time
(if they do their job and moderate news as soon as they see them), (if they do their job and moderated news as soon as they see them),
so it's still reasonable #} so it's still reasonable #}
<div <div
{% if news is integer or news is string -%} {% if news is integer or news is string %}
x-data="{ nbEvents: 0 }" x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()" x-init="nbEvents = await nbToPublish()"
{%- elif news.is_published -%} {% else %}
x-data="{ nbEvents: 0 }"
{%- else -%}
x-data="{ nbEvents: {{ news.dates.count() }} }" x-data="{ nbEvents: {{ news.dates.count() }} }"
{%- endif -%} {% endif %}
> >
<template x-if="nbEvents > 1"> <template x-if="nbEvents > 1">
<div> <div>

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

@@ -77,22 +77,22 @@
<div class="notification" x-data="{display: false}" :class="{white: display}"> <div class="notification" x-data="{display: false}" :class="{white: display}">
<a href="#" @click.prevent="display = !display"> <a href="#" @click.prevent="display = !display">
<i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></i> <i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></i>
{% set notifications = user.notifications.filter(viewed=False).order_by("-date")|list %} {% set notification_count = user.notifications.filter(viewed=False).count() %}
{%- if notifications|length > 0 -%} {% if notification_count > 0 %}
<span> <span>
{% if notifications|length < 100 %} {% if notification_count < 100 %}
{{ notifications|length }} {{ notification_count }}
{%- else -%} {% else %}
99+ &nbsp;
{%- endif -%} {% endif %}
</span> </span>
{% endif %} {% endif %}
</a> </a>
<div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false"> <div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false">
<ul> <ul>
{%- if notifications|length > 0 -%} {% if user.notifications.filter(viewed=False).count() > 0 %}
{%- for n in notifications -%} {% for n in user.notifications.filter(viewed=False).order_by('-date') %}
<li> <li>
<a href="{{ url("core:notification", notif_id=n.id) }}"> <a href="{{ url("core:notification", notif_id=n.id) }}">
<div class="datetime"> <div class="datetime">
@@ -108,10 +108,10 @@
</div> </div>
</a> </a>
</li> </li>
{%- endfor -%} {% endfor %}
{%- else -%} {% else %}
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li> <li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
{%- endif -%} {% endif %}
</ul> </ul>
<div class="options"> <div class="options">
<a href="{{ url('core:notification_list') }}"> <a href="{{ url('core:notification_list') }}">

View File

@@ -1,25 +1,23 @@
{% spaceless %} {% for js in statics.js %}
{% for js in statics.js %} <script-once type="module" src="{{ js }}"></script-once>
<script-once type="module" src="{{ js }}"></script-once> {% endfor %}
{% endfor %} {% for css in statics.css %}
{% for css in statics.css %} <link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once> {% endfor %}
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}> <{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %} {% for group_name, group_choices, group_index in widget.optgroups %}
{% if group_name %} {% if group_name %}
<optgroup label="{{ group_name }}"> <optgroup label="{{ group_name }}">
{% endif %}
{% for widget in group_choices %}
{% include widget.template_name %}
{% endfor %}
{% if group_name %}
</optgroup>
{% endif %}
{% endfor %}
{% if initial %}
<slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %} {% endif %}
</{{ component }}> {% for widget in group_choices %}
{% endspaceless %} {% include widget.template_name %}
{% endfor %}
{% if group_name %}
</optgroup>
{% endif %}
{% endfor %}
{% if initial %}
<slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %}
</{{ component }}>

View File

@@ -881,6 +881,7 @@ class Selling(models.Model):
if ( if (
self.product self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
and self.counter.type == "EBOUTIC"
): ):
sub = Subscription( sub = Subscription(
member=user, member=user,
@@ -904,6 +905,7 @@ class Selling(models.Model):
elif ( elif (
self.product self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
and self.counter.type == "EBOUTIC"
): ):
sub = Subscription( sub = Subscription(
member=user, member=user,

View File

@@ -39,7 +39,6 @@
flex: auto; flex: auto;
margin: 0.2em; margin: 0.2em;
width: 20%; width: 20%;
min-width: 350px;
ul { ul {
list-style-type: none; list-style-type: none;

View File

@@ -67,13 +67,13 @@
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option> <option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup> </optgroup>
{%- for category in categories.keys() -%} {% for category in categories.keys() %}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for product in categories[category] -%} {% for product in categories[category] %}
<option value="{{ product.id }}">{{ product }}</option> <option value="{{ product.id }}">{{ product }}</option>
{%- endfor -%} {% endfor %}
</optgroup> </optgroup>
{%- endfor -%} {% endfor %}
</counter-product-select> </counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>

View File

@@ -45,9 +45,8 @@ 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:
@@ -60,3 +59,6 @@ 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,14 +31,13 @@ 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, Exists, F, OuterRef, Q, QuerySet from django.db.models import Count, F, Q, QuerySet
from django.utils.timezone import localdate, now from django.utils.timezone import localdate
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):
@@ -199,16 +198,8 @@ 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.filter(is_subscriber_viewable=True) User.objects.exclude(subscriptions=None)
.exclude(subscriptions=None) .annotate(pictures_count=Count("pictures"))
.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()
) )
@@ -299,9 +290,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.values("start_date", "end_date", "club_id") memberships = user.memberships.only("start_date", "end_date", "club_id")
result = defaultdict(int) result = defaultdict(int)
today = localdate() now = 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.
@@ -309,23 +300,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 <= today | Q( # start2 <= start1 <= now
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 today, 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") .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 today, other.end_date or today) end = min(membership.end_date or now, other.end_date or now)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result return result
@@ -391,22 +382,18 @@ 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_qs = self.get_rulable_users(picture_count_threshold) rulable_users = list(self.get_rulable_users(picture_count_threshold))
active_users_count = rulable_users_qs.filter(is_active_in_galaxy=True).count() rulable_users_count = len(rulable_users)
rulable_users = list(rulable_users_qs)
user1_count = 0 user1_count = 0
self.logger.info( self.logger.info(
f" {len(rulable_users)} citizens (with {active_users_count} active ones) " f"{rulable_users_count} citizen have been listed. Starting to rule."
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( GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
owner_id=user.id, galaxy=self, mass=individual_scores[user.id]
)
for user in rulable_users for user in rulable_users
] ]
) )
@@ -418,9 +405,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 = []
@@ -461,20 +448,17 @@ 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}/{active_users_count} " f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {active_users_count - user1_count} remaining" f"citizen -- {rulable_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 = len(rulable_users) // global_avg_speed eta = rulable_users_count2 // 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(38): with self.assertNumQueries(44):
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(36): with self.assertNumQueries(39):
galaxy.rule(0) # We want everybody here galaxy.rule(0) # We want everybody here

View File

@@ -1061,10 +1061,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"
@@ -5240,18 +5236,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"

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",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",
@@ -3106,15 +3105,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",
@@ -3503,15 +3493,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",
@@ -4184,19 +4165,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",
@@ -5486,15 +5454,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",
@@ -5752,15 +5711,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.6", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",

View File

@@ -59,7 +59,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",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",

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,184 +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,
endSlot: 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],
};
},
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();
},
}));
});

View File

@@ -1,67 +0,0 @@
@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;
}
}
}
}

View File

@@ -1,68 +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+80}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">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<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>
</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"