Compare commits

..

1 Commits

Author SHA1 Message Date
Kenneth SOARES
2550fd0af7 update subscription price 2025-10-06 13:37:50 +02:00
25 changed files with 86 additions and 487 deletions

View File

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

View File

@@ -205,10 +205,6 @@
<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,6 +651,9 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property
def was_subscribed(self):
return False
@@ -659,6 +662,10 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self):
return False
@property
def subscribed(self):
return False
@property
def is_root(self):
return False

View File

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

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

@@ -1061,10 +1061,6 @@ 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"
@@ -4978,47 +4974,47 @@ msgstr "Suppression de rechargement"
#: sith/settings.py
msgid "One semester"
msgstr "Un semestre, 20 €"
msgstr "Un semestre"
#: sith/settings.py
msgid "Two semesters"
msgstr "Deux semestres, 35 €"
msgstr "Deux semestres"
#: sith/settings.py
msgid "Common core cursus"
msgstr "Cursus tronc commun, 60 €"
msgstr "Cursus tronc commun"
#: sith/settings.py
msgid "Branch cursus"
msgstr "Cursus branche, 60 €"
msgstr "Cursus branche"
#: sith/settings.py
msgid "Alternating cursus"
msgstr "Cursus alternant, 30 €"
msgstr "Cursus alternant"
#: sith/settings.py
msgid "Honorary member"
msgstr "Membre honoraire, 0 €"
msgstr "Membre honoraire"
#: sith/settings.py
msgid "Assidu member"
msgstr "Membre d'Assidu, 0 €"
msgstr "Membre d'Assidu"
#: sith/settings.py
msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO, 0 €"
msgstr "Membre de l'Amicale/DOCEO"
#: sith/settings.py
msgid "UT network member"
msgstr "Cotisant du réseau UT, 0 €"
msgstr "Cotisant du réseau UT"
#: sith/settings.py
msgid "CROUS member"
msgstr "Membres du CROUS, 0 €"
msgstr "Membres du CROUS"
#: sith/settings.py
msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA, 20 €"
msgstr "Membre de Sbarro ou de l'ESTA"
#: sith/settings.py
msgid "One semester Welcome Week"
@@ -5045,28 +5041,28 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
msgid "GA staff member"
msgstr "Membre staff GA (2 semaines), 1 €"
msgid "GA staff member (2 weeks)"
msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py
msgid "One semester (-20%)"
msgstr "Un semestre (-20%), 12 €"
msgstr "Un semestre (-20%)"
#: sith/settings.py
msgid "Two semesters (-20%)"
msgstr "Deux semestres (-20%), 22 €"
msgstr "Deux semestres (-20%)"
#: sith/settings.py
msgid "Common core cursus (-20%)"
msgstr "Cursus tronc commun (-20%), 36 €"
msgstr "Cursus tronc commun (-20%)"
#: sith/settings.py
msgid "Branch cursus (-20%)"
msgstr "Cursus branche (-20%), 36 €"
msgstr "Cursus branche (-20%)"
#: sith/settings.py
msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%), 24 €"
msgstr "Cursus alternant (-20%)"
#: sith/settings.py
msgid "One year for free(CA offer)"
@@ -5240,18 +5236,6 @@ 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"

50
package-lock.json generated
View File

@@ -29,7 +29,6 @@
"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",
@@ -3106,15 +3105,6 @@
"@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",
@@ -3503,15 +3493,6 @@
"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,19 +4165,6 @@
"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",
@@ -5486,15 +5454,6 @@
"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",
@@ -5752,15 +5711,6 @@
"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",

View File

@@ -59,7 +59,6 @@
"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",

View File

@@ -125,7 +125,6 @@ INSTALLED_APPS = (
"pedagogy",
"galaxy",
"antispam",
"timetable",
"api",
)
@@ -542,7 +541,7 @@ SITH_SUBSCRIPTIONS = {
"duration": 4,
},
"cursus-branche": {"name": _("Branch cursus"), "price": 60, "duration": 6},
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
"cursus-alternant": {"name": _("Alternating cursus"), "price": 35, "duration": 6},
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
@@ -554,8 +553,6 @@ SITH_SUBSCRIPTIONS = {
"price": 0,
"duration": 1,
},
"un-mois-essai": {"name": _("One month for free"), "price": 0, "duration": 0.166},
"deux-mois-essai": {"name": _("Two months for free"), "price": 0, "duration": 0.33},
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"six-semaines-essai": {
"name": _("Six weeks for free"),

View File

@@ -53,7 +53,6 @@ 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:

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.3 on 2025-10-06 11:24
from django.db import migrations, models
import subscription.models
class Migration(migrations.Migration):
dependencies = [("subscription", "0015_alter_subscription_location_and_more")]
operations = [
migrations.AlterField(
model_name="subscription",
name="subscription_type",
field=models.CharField(
choices=subscription.models.get_subscription_types,
max_length=255,
verbose_name="subscription type",
),
)
]

View File

@@ -38,16 +38,19 @@ def validate_payment(value):
raise ValidationError(_("Bad payment method"))
def get_subscription_types():
return (
(k, f"{v['name']}, {v['price']}")
for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
)
class Subscription(models.Model):
member = models.ForeignKey(
User, related_name="subscriptions", on_delete=models.CASCADE
)
subscription_type = models.CharField(
_("subscription type"),
max_length=255,
choices=(
(k, v["name"]) for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
),
_("subscription type"), max_length=255, choices=get_subscription_types
)
subscription_start = models.DateField(_("subscription start"))
subscription_end = models.DateField(_("subscription end"))

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"