Compare commits

..
24 Commits
Author SHA1 Message Date
imperosol 622252d528 ts-ify guide-index.ts 2026-09-04 16:12:57 +02:00
thomas girod d2c259c4f9 Merge pull request #1474 from ae-utbm/404-msg
funny 404 message
2026-09-04 16:12:37 +02:00
thomas girod f57fe290d6 Merge pull request #1475 from ae-utbm/fix-timetable-regex
fix: timetable
2026-09-04 15:55:37 +02:00
imperosol e7b5330f4f make timetable generator available to everyone 2026-09-04 15:50:21 +02:00
imperosol 3cdbb9605c fix: timetable regex 2026-09-04 15:47:46 +02:00
imperosol c17f2feefc funny 404 message 2026-09-04 11:25:06 +02:00
thomas girod 22ecd01898 Merge pull request #1473 from ae-utbm/js-markdown
fix: use good version of aemark
2026-09-03 16:31:48 +02:00
imperosol 1fa4ccd710 fix: use good version of aemark 2026-09-03 16:30:05 +02:00
thomas girod 2a71708ccd Merge pull request #1472 from ae-utbm/js-markdown
Js markdown
2026-09-03 16:22:43 +02:00
imperosol 549d8c67e7 doc: ae markdown 2026-09-03 16:10:18 +02:00
imperosol ff69741c82 remove /api/markdown route 2026-09-03 12:03:52 +02:00
imperosol b5b837498f Use client-side md parser for easymde 2026-09-03 11:50:55 +02:00
imperosol 8ef3054888 add @ae_utbm/aemark to JS deps 2026-09-03 11:50:55 +02:00
thomas girod 362f1a6a62 Merge pull request #1470 from ae-utbm/subscription-creation-date
add created_at column to Subscription
2026-09-01 23:35:33 +02:00
imperosol b6347829c6 add translations 2026-09-01 18:34:03 +02:00
imperosol 2099e1bd36 add created_at column to Subscription 2026-09-01 15:45:51 +02:00
thomas girod 7fca5d8c75 Merge pull request #1468 from ae-utbm/fix-duplicate-user-search
fix: duplicate user search when a whitelist exists
2026-08-31 18:03:19 +02:00
imperosol 3467aad846 fix: duplicate user search when a whitelist exists
Quand un utilisateur possède une whitelist d'utilisateurs, qu'il effectue une recherche et qu'il apparait dans les résultats, son profil apparait plusieurs fois.
2026-08-31 16:52:42 +02:00
klmp200 ebfac638de Merge pull request #1465 from ae-utbm/hotfix
Fix broken product widget
2026-08-30 22:40:08 +02:00
klmp200 839536661b Merge pull request #1466 from ae-utbm/htmx4
Migrate to HTMX4
2026-08-30 22:39:52 +02:00
thomas girod b1b3639dc9 Merge pull request #1467 from ae-utbm/pedagogy-style
Pedagogy style
2026-08-30 22:38:34 +02:00
klmp200 56c832ba06 Migrate to HTMX4 2026-08-30 22:07:53 +02:00
imperosol 884019d803 style: standardize UE search style 2026-08-30 21:53:00 +02:00
klmp200 dfc714ff3d Fix broken ProductAjaxSelect widget 2026-08-28 15:05:17 +02:00
27 changed files with 280 additions and 231 deletions
@@ -7,7 +7,7 @@
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
hx-swap="outerHTML"
hx-target="#member-fragment-container"
id="add_club_members_form"
-11
View File
@@ -3,7 +3,6 @@ from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
@@ -18,7 +17,6 @@ from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
@@ -28,18 +26,9 @@ from core.schemas import (
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown")
class MarkdownController(ControllerBase):
@route.post("", url_name="markdown")
def render_markdown(self, body: MarkdownSchema):
"""Convert the markdown text into html."""
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/upload")
class UploadController(ControllerBase):
@route.post(
-4
View File
@@ -161,10 +161,6 @@ class UserFilterSchema(FilterSchema):
return value
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema):
godfather: int
godchild: int
+10 -5
View File
@@ -6,10 +6,15 @@
* for more efficient tree-shaking and gzip compression.
*/
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
@@ -44,16 +49,16 @@ polyfillCountryFlagEmojis();
* HTMX
*/
document.body.addEventListener(
"htmx:beforeRequest" as keyof HTMLElementEventMap,
"htmx:before:request" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = true;
(event as CustomEvent).detail.ctx.target.ariaBusy = true;
},
);
document.body.addEventListener(
"htmx:beforeSwap" as keyof HTMLElementEventMap,
"htmx:before:swap" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = null;
(event as CustomEvent).detail.ctx.target.ariaBusy = null;
},
);
@@ -3,16 +3,12 @@
import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css";
import { markdown } from "@ae_utbm/aemark";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import type CodeMirror from "codemirror"; // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage,
} from "#openapi";
import { type UploadUploadImageErrors, uploadUploadImage } from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
const easymde = new EasyMDE({
@@ -64,19 +60,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText, preview) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce(() => {
const func = async () => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func().then();
return null;
}, 300)();
},
previewRender: (plainText) => markdown(plainText),
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
+4
View File
@@ -4,6 +4,10 @@
<div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
<blockquote>
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
</blockquote>
</div>
{% endblock %}
@@ -1,6 +1,6 @@
<form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
>
{% for message in messages %}
+16
View File
@@ -141,6 +141,22 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search"))
assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db
def test_user_account_not_found(client: Client):
+1
View File
@@ -65,6 +65,7 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
.distinct()
)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
@@ -28,18 +28,23 @@ export class ProductAjaxSelect extends AjaxSelect {
return [];
}
private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
// In the context in which this method is called, `this` might be shadowed
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
}
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${this.getName(item, sanitize)}</span>
<span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span>
</div>`;
}
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${this.getName(item, sanitize)}</span>`;
return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`;
}
}
@@ -1,5 +1,10 @@
<div id="student_card_form">
<form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
<form
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
hx-disable="input[type='submit']"
>
{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
+1 -1
View File
@@ -217,7 +217,7 @@ C'est une technologie simple et puissante qui se veut comme le jQuery du web mod
### Htmx
[Site officiel](https://htmx.org/)
[Site officiel](https://four.htmx.org/)
En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx.
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
+51
View File
@@ -0,0 +1,51 @@
## syntaxe aemark
Le site AE utilise markdown pour le rendu de la plupart
des textes saisis par les utilisateurs.
Cependant, la syntaxe utilisée n'est pas celle officielle
telle que définie par John Gruber,
mais est basée sur [CommonMark](https://commonmark.org/),
avec quelques variations pour les usages particuliers du site AE.
Les deux principaux ajouts sont :
- Les urls commençant par `page://` sont modifiées pour commencer par `/page/`
- Des modificateurs de taille peuvent être indiqués directement dans la source
d'une image
Les variations d'aemark sont documentées sur
[le site AE](https://ae.utbm.fr/page/Aide_sur_la_syntaxe/).
Le code est hébergé sur le dépôt Git [aemark](https://github.com/ae-utbm/ae-markdown).
## Utiliser aemark
Le code du parser aemark est écrit en Rust dans une librairie
indépendante, avec des bindings vers différents langages.
Les librairies mises à disposition exposent une seule fonction,
qui prend simplement du markdown en entrée et renvoie l'HTML correspondant.
Les librairies pour les différents langages sont toutes
basées sur le même code Rust.
La seule différence entre chacune tient uniquement dans la manière
d'intégrer ce code dans les bindings.
De cette manière, le comportement est assuré d'être le même
sur toutes les plateformes disponibles, avec en prime des performances
respectables.
=== ":simple-python: Python"
```python
from aemark import markdown
result = markdown("this is some *markdown text* with __formatting__")
```
=== ":simple-javascript: Javascript"
```typescript
import { markdown } from "@ae_utbm/aemark"
const result = markdown("this is some *markdown text* with __formatting__");
```
@@ -41,7 +41,7 @@
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
hx-swap="outerHTML"
hx-target="#apply-election-result-fragment"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
>
{% csrf_token %}
{{ form }}
+21 -1
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-21 14:10+0200\n"
"POT-Creation-Date: 2026-09-04 11:24+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"
@@ -2049,6 +2049,10 @@ msgstr "403, Non autorisé"
msgid "404, Not Found"
msgstr "404. Non trouvé"
#: core/templates/core/404.jinja
msgid "Impossible, perhaps the archives are incomplete"
msgstr "Impossible, les archives sont peut-être incomplètes"
#: core/templates/core/500.jinja
msgid "500, Server Error"
msgstr "500, Erreur Serveur"
@@ -5089,6 +5093,10 @@ msgstr "signalant"
msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM."
#: pedagogy/templates/pedagogy/guide.jinja
msgid "Search UE"
msgstr "Recherche d'UE"
#: pedagogy/templates/pedagogy/guide.jinja
#, python-format
msgid "%(display_name)s"
@@ -5758,6 +5766,18 @@ msgstr "fin de la cotisation"
msgid "location"
msgstr "lieu"
#: subscription/models.py
msgid "created_at"
msgstr "créé le"
#: subscription/models.py
msgid ""
"When this subscription was created. This date may differ from the start of "
"the subscription."
msgstr ""
"Quand la cotisation a été créée. Cette date peut différer du début effectif "
"de la cotisation."
#: subscription/models.py
msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
+1
View File
@@ -71,6 +71,7 @@ nav:
- API:
- Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md
- Markdown AE: tutorial/markdown.md
- Etransactions: tutorial/etransaction.md
- How-to:
- L'ORM de Django: howto/querysets.md
+17 -5
View File
@@ -9,6 +9,7 @@
"version": "3",
"license": "GPL-3.0-only",
"dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0",
@@ -30,7 +31,7 @@
"easymde": "^2.21.0",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.10",
"htmx.org": "^4.0.0",
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
@@ -56,6 +57,11 @@
"vite": "^8.2.2"
}
},
"node_modules/@ae_utbm/aemark": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@ae_utbm/aemark/-/aemark-0.1.3.tgz",
"integrity": "sha512-JHb9XDgYxy9J0f7bUWtS7eVptKbeB/4+VQoHO3g1ASwNHZpIpW+RRdJPXA+oi2/RDrdPbEtCMkkVROc+8ifFYQ=="
},
"node_modules/@alpinejs/sort": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.16.2.tgz",
@@ -3801,10 +3807,16 @@
}
},
"node_modules/htmx.org": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "0BSD"
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-4.0.0.tgz",
"integrity": "sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==",
"license": "BSD-0-Clause",
"workspaces": [
"ext/*"
],
"bin": {
"upgrade-check": "dist/scripts/upgrade-check.js"
}
},
"node_modules/ical.js": {
"version": "1.5.0",
+3 -2
View File
@@ -34,12 +34,13 @@
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^26.2.0",
"rollup-plugin-visualizer": "^7.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2"
},
"dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0",
@@ -61,7 +62,7 @@
"easymde": "^2.21.0",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.10",
"htmx.org": "^4.0.0",
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
@@ -1,9 +1,5 @@
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import { ueFetchUeList } from "#openapi";
import { getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import { type SimpleUeSchema, ueFetchUeList } from "#openapi";
const pageDefault = 1;
const pageSizeDefault = 100;
@@ -12,38 +8,42 @@ document.addEventListener("alpine:init", () => {
Alpine.data("ue_search", () => ({
ues: {
count: 0,
next: null,
previous: null,
results: [],
next: null as string | null,
previous: null as string | null,
results: [] as SimpleUeSchema[],
},
loading: false,
page: pageDefault,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: pageSizeDefault,
search: "",
department: [],
department: [] as string[],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: [],
semester: [],
credit_type: [] as string[],
semester: [] as string[],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
to_change: [],
pushstate: History.Push,
to_change: [] as { param: string; value: string }[],
update: undefined,
// dummy implementation to make TS happy.
// The real function is initialized in init
update: () => {
console.warn("Update not yet initialized");
},
initializeArgs() {
const url = getCurrentUrlParams();
this.pushstate = History.Replace;
this.page = Number.parseInt(url.get("page"), 10) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size"), 10) || pageSizeDefault;
this.page = Number.parseInt(url.get("page") || pageDefault.toString(), 10);
this.page_size = Number.parseInt(
url.get("page_size") || pageSizeDefault.toString(),
10,
);
this.search = url.get("search") || "";
this.department = url.getAll("department");
this.credit_type = url.getAll("credit_type");
/* The semester is easier to use on the backend as an enum (spring/autumn/both/none)
and easier to use on the frontend as an array ([spring, autumn]).
Thus there is some conversion involved when both communicate together */
this.semester = url.has("semester") ? url.get("semester").split("_AND_") : [];
this.semester = url.get("semester")?.split("_AND_") || [];
this.update();
},
@@ -51,15 +51,11 @@ document.addEventListener("alpine:init", () => {
async init() {
this.update = Alpine.debounce(async () => {
/* Create the whole url before changing everything all at once */
const first = this.to_change.shift();
let url = updateQueryString(first.param, first.value, History.None);
for (const value of this.to_change) {
url = updateQueryString(value.param, value.value, History.None, url);
for (const val of this.to_change) {
updateQueryString(val.param, val.value);
}
updateQueryString(first.param, first.value, this.pushstate, url);
await this.fetchData(); /* reload data on form change */
this.to_change = [];
this.pushstate = History.Push;
}, 50);
const searchParams = ["search", "department", "credit_type", "semester"];
@@ -67,47 +63,37 @@ document.addEventListener("alpine:init", () => {
for (const param of searchParams) {
this.$watch(param, () => {
if (this.pushstate !== History.Push) {
/* This means that we are doing a mass param edit */
return;
}
/* Reset pagination on search */
this.page = pageDefault;
this.page_size = pageSizeDefault;
});
}
for (const param of searchParams.concat(paginationParams)) {
this.$watch(param, (value) => {
this.$watch(param, (value: string) => {
this.to_change.push({ param: param, value: value });
this.update();
});
}
window.addEventListener("popstate", () => {
this.initializeArgs();
});
this.initializeArgs();
},
async fetchData() {
this.loading = true;
const args = {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.page_size,
};
for (const [param, value] of new URL(
window.location.href,
).searchParams.entries()) {
// Deal with array type params
if (["credit_type", "department", "semester"].includes(param)) {
if (args[param] === undefined) {
args[param] = [];
}
args[param].push(value);
} else {
args[param] = value;
}
const res = await ueFetchUeList({
query: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.page_size,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: this.credit_type.length > 0 ? this.credit_type : undefined,
semester: this.semester.length > 0 ? this.semester : undefined,
department: this.department.length > 0 ? this.department : undefined,
search: this.search || undefined,
},
});
if (res.data !== undefined) {
this.ues = res.data;
}
this.ues = (await ueFetchUeList({ query: args })).data;
this.loading = false;
},
+11 -88
View File
@@ -64,100 +64,23 @@ $pedagogy-white-text: #f0f0f0;
}
#search_form {
.search-form-container {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto auto;
grid-template-areas:
"action-bar action-bar"
"search-bar search-bar"
"radio-department radio-department"
"radio-credit-type radio-semester";
}
.action-bar {
grid-area: action-bar;
margin-bottom: 10px;
}
.search-bar {
grid-area: search-bar;
display: grid;
grid-template-columns: auto 200px;
grid-template-rows: auto;
grid-template-areas: "search-bar-input search-bar-button";
@media screen and (max-width: $medium-devices) {
grid-template-columns: auto auto;
grid-template-rows: auto;
grid-template-areas: "search-bar-input search-bar-button";
.radio-guide fieldset {
input[type="checkbox"] {
display: none;
}
@media screen and (max-width: $small-devices) {
grid-template-columns: auto;
grid-template-rows: auto;
grid-template-areas: "search-bar-input";
.search-bar-button {
display: none;
}
}
.search-bar-input {
grid-area: search-bar-input;
background: $pedagogy-light-blue;
}
.search-bar-button {
grid-area: search-bar-button;
background: $pedagogy-orange;
color: white;
font-weight: bold;
margin-left: 20px;
}
}
.radio-department {
grid-area: radio-department;
}
.radio-credit-type {
grid-area: radio-credit-type;
}
.radio-semester {
grid-area: radio-semester;
}
.radio-guide input[type="radio"],
input[type="checkbox"] {
display: none;
}
.radio-guide {
margin-top: 10px;
margin-bottom: 0;
color: white;
}
.radio-guide label {
display: inline-block;
background-color: $pedagogy-blue;
padding: 10px 20px;
font-family: Arial, sans-serif;
font-size: 16px;
border-radius: 4px;
}
label {
padding: 10px 20px;
}
.radio-guide input[type="radio"]:checked+label {
background-color: $pedagogy-orange;
}
.radio-guide input[type="checkbox"]:checked+label {
background-color: $pedagogy-orange;
}
.radio-guide label:hover {
background-color: $pedagogy-hover-blue;
input[type="checkbox"]:checked+label {
background-color: $pedagogy-orange;
@include shadow;
}
}
}
+30 -29
View File
@@ -14,7 +14,7 @@
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static('bundled/pedagogy/guide-index.js') }}"></script>
<script type="module" src="{{ static('bundled/pedagogy/guide-index.ts') }}"></script>
{% endblock %}
{% block head %}
@@ -24,30 +24,28 @@
{% block content %}
{% if user.has_perm("pedagogy.add_ue") %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:ue_create') }}">{% trans %}Create UE{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
</p>
<div class="row gap">
<a href="{{ url('pedagogy:ue_create') }}" class="btn btn-blue"><i class="fa fa-plus"></i>{% trans %}Create UE{% endtrans %}</a>
<a href="{{ url('pedagogy:moderation') }}" class="btn btn-grey">{% trans %}Moderate comments{% endtrans %}</a>
</div>
<br/>
{% endif %}
<div class="pedagogy" x-data="ue_search" x-cloak>
<form id="search_form">
<form id="search_form" class="">
<div class="search-form-container">
<div class="search-bar">
<fieldset>
<label for="search_input">{% trans %}Search UE{% endtrans %}</label>
<input
id="search_input"
class="search-bar-input"
type="text"
type="search"
name="search"
placeholder=""
x-model.debounce.500ms="search"
/>
</div>
<div class="radio-department">
<div class="radio-guide">
</fieldset>
<div class="row gap-3x margin-bottom radio-guide">
<fieldset>
{% set departments = [
("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"),
("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")
@@ -56,16 +54,16 @@
<input
type="checkbox"
name="department"
id="radio{{ real_name }}"
id="radio_{{ real_name }}"
value="{{ real_name }}"
x-model="department"
/>
<label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
<label for="radio_{{ real_name }}" class="btn btn-blue">
{% trans %}{{ display_name }}{% endtrans %}
</label>
{% endfor %}
</div>
</div>
<div class="radio-credit-type">
<div class="radio-guide">
</fieldset>
<fieldset>
{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %}
<input
type="checkbox"
@@ -74,18 +72,21 @@
value="{{ credit_type }}"
x-model="credit_type"
/>
<label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
<label for="radio{{ credit_type }}" class="btn btn-blue">
{% trans %}{{ credit_type }}{% endtrans %}
</label>
{% endfor %}
</div>
</div>
<div class="radio-semester">
<div class="radio-guide">
</fieldset>
<fieldset>
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/>
<label for="radioAUTUMN"><i class="fa fa-leaf"></i></label>
<label for="radioAUTUMN" class="btn btn-no-text btn-blue">
<i class="fa fa-leaf"></i>
</label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/>
<label for="radioSPRING"><i class="fa-regular fa-sun"></i></label>
</div>
<label for="radioSPRING" class="btn btn-no-text btn-blue">
<i class="fa-regular fa-sun"></i>
</label>
</fieldset>
</div>
</div>
</form>
@@ -0,0 +1,43 @@
# Generated by Django 5.2.17 on 2026-09-01 08:44
import django.utils.timezone
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F, Value
from django.db.models.functions import Least
from django.utils.timezone import now
def make_default_creation_date(apps: StateApps, schema_editor):
Subscription = apps.get_model("subscription", "Subscription")
Subscription.objects.update(
created_at=Least(
F("subscription_start"), Value(now()), output_field=models.DateTimeField()
)
)
class Migration(migrations.Migration):
dependencies = [("subscription", "0016_alter_subscription_subscription_type")]
operations = [
migrations.AddField(
model_name="subscription",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
help_text=(
"When this subscription was created. "
"This date may differ from the start of the subscription."
),
verbose_name="created_at",
),
preserve_default=False,
),
migrations.RunPython(
make_default_creation_date,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]
+8
View File
@@ -64,6 +64,14 @@ class Subscription(models.Model):
max_length=20,
verbose_name=_("location"),
)
created_at = models.DateTimeField(
_("created_at"),
help_text=_(
"When this subscription was created. "
"This date may differ from the start of the subscription."
),
auto_now_add=True,
)
class Meta:
ordering = ["subscription_start"]
@@ -1,7 +1,7 @@
<form
hx-post="{{ url("subscription:fragment-existing-user") }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
@@ -1,7 +1,7 @@
<form
hx-post="{{ url("subscription:fragment-new-user") }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-disable="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
@@ -1,8 +1,8 @@
import html2canvas from "html2canvas";
// see https://regex101.com/r/QHSaPM/2
// see https://regex101.com/r/QHSaPM/3
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+)?)$/;
/^(?<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
+1 -3
View File
@@ -1,8 +1,6 @@
# Create your views here.
from django.views.generic import TemplateView
from core.auth.mixins import FormerSubscriberMixin
class GeneratorView(FormerSubscriberMixin, TemplateView):
class GeneratorView(TemplateView):
template_name = "timetable/generator.jinja"