mirror of
https://github.com/ae-utbm/sith.git
synced 2026-09-04 19:44:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe1a2529f |
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
|
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
|
||||||
hx-disable="find input[type='submit']"
|
hx-disabled-elt="find input[type='submit']"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-target="#member-fragment-container"
|
hx-target="#member-fragment-container"
|
||||||
id="add_club_members_form"
|
id="add_club_members_form"
|
||||||
|
|||||||
+11
@@ -3,6 +3,7 @@ from typing import Annotated, Literal
|
|||||||
from annotated_types import Ge, Le, MinLen
|
from annotated_types import Ge, Le, MinLen
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
from django.http import HttpResponse
|
||||||
from ninja import File, Query
|
from ninja import File, Query
|
||||||
from ninja.security import SessionAuth
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
@@ -17,6 +18,7 @@ from core.models import Group, QuickUploadImage, SithFile, User
|
|||||||
from core.schemas import (
|
from core.schemas import (
|
||||||
FamilyGodfatherSchema,
|
FamilyGodfatherSchema,
|
||||||
GroupSchema,
|
GroupSchema,
|
||||||
|
MarkdownSchema,
|
||||||
SithFileSchema,
|
SithFileSchema,
|
||||||
UploadedFileSchema,
|
UploadedFileSchema,
|
||||||
UploadedImage,
|
UploadedImage,
|
||||||
@@ -26,9 +28,18 @@ from core.schemas import (
|
|||||||
UserSchema,
|
UserSchema,
|
||||||
ValidationErrorSchema,
|
ValidationErrorSchema,
|
||||||
)
|
)
|
||||||
|
from core.templatetags.renderer import markdown
|
||||||
from counter.utils import is_logged_in_counter
|
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")
|
@api_controller("/upload")
|
||||||
class UploadController(ControllerBase):
|
class UploadController(ControllerBase):
|
||||||
@route.post(
|
@route.post(
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ class UserFilterSchema(FilterSchema):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownSchema(Schema):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
class FamilyGodfatherSchema(Schema):
|
class FamilyGodfatherSchema(Schema):
|
||||||
godfather: int
|
godfather: int
|
||||||
godchild: int
|
godchild: int
|
||||||
|
|||||||
@@ -6,15 +6,10 @@
|
|||||||
* for more efficient tree-shaking and gzip compression.
|
* 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 sort from "@alpinejs/sort";
|
||||||
import Alpine from "alpinejs";
|
import Alpine from "alpinejs";
|
||||||
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
|
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
|
||||||
|
import htmx from "htmx.org";
|
||||||
import { limitedChoices } from "#core:alpine/limited-choices";
|
import { limitedChoices } from "#core:alpine/limited-choices";
|
||||||
import { expireOldStorage } from "#core:core/localstorage";
|
import { expireOldStorage } from "#core:core/localstorage";
|
||||||
import { default as navbar } from "#core:core/navbar";
|
import { default as navbar } from "#core:core/navbar";
|
||||||
@@ -49,16 +44,16 @@ polyfillCountryFlagEmojis();
|
|||||||
* HTMX
|
* HTMX
|
||||||
*/
|
*/
|
||||||
document.body.addEventListener(
|
document.body.addEventListener(
|
||||||
"htmx:before:request" as keyof HTMLElementEventMap,
|
"htmx:beforeRequest" as keyof HTMLElementEventMap,
|
||||||
(event) => {
|
(event) => {
|
||||||
(event as CustomEvent).detail.ctx.target.ariaBusy = true;
|
(event as CustomEvent).detail.target.ariaBusy = true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
document.body.addEventListener(
|
document.body.addEventListener(
|
||||||
"htmx:before:swap" as keyof HTMLElementEventMap,
|
"htmx:beforeSwap" as keyof HTMLElementEventMap,
|
||||||
(event) => {
|
(event) => {
|
||||||
(event as CustomEvent).detail.ctx.target.ariaBusy = null;
|
(event as CustomEvent).detail.target.ariaBusy = null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
import "codemirror/lib/codemirror.css";
|
import "codemirror/lib/codemirror.css";
|
||||||
// @ts-expect-error 2307
|
// @ts-expect-error 2307
|
||||||
import "easymde/src/css/easymde.css";
|
import "easymde/src/css/easymde.css";
|
||||||
import { markdown } from "@ae_utbm/aemark";
|
|
||||||
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
|
// 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 EasyMDE from "easymde";
|
||||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
import { type UploadUploadImageErrors, uploadUploadImage } from "#openapi";
|
import {
|
||||||
|
markdownRenderMarkdown,
|
||||||
|
type UploadUploadImageErrors,
|
||||||
|
uploadUploadImage,
|
||||||
|
} from "#openapi";
|
||||||
|
|
||||||
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||||
const easymde = new EasyMDE({
|
const easymde = new EasyMDE({
|
||||||
@@ -60,7 +64,19 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
});
|
});
|
||||||
easymde.codemirror.replaceSelection("\n");
|
easymde.codemirror.replaceSelection("\n");
|
||||||
},
|
},
|
||||||
previewRender: (plainText) => markdown(plainText),
|
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)();
|
||||||
|
},
|
||||||
forceSync: true, // Avoid validation error on generic create view
|
forceSync: true, // Avoid validation error on generic create view
|
||||||
imageTexts: {
|
imageTexts: {
|
||||||
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
|
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
|
|
||||||
<div id="page">
|
<div id="page">
|
||||||
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
|
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
|
||||||
|
|
||||||
<blockquote>
|
|
||||||
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<form
|
<form
|
||||||
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
|
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
|
||||||
hx-disable="find input[type='submit']"
|
hx-disabled-elt="find input[type='submit']"
|
||||||
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
|
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
|
||||||
>
|
>
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
|||||||
@@ -141,22 +141,6 @@ class TestSearchUsersView(TestSearchUsers):
|
|||||||
response = self.client.get(reverse("core:search"))
|
response = self.client.get(reverse("core:search"))
|
||||||
assert response.status_code == 200
|
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
|
@pytest.mark.django_db
|
||||||
def test_user_account_not_found(client: Client):
|
def test_user_account_not_found(client: Client):
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
|
|||||||
UserFilterSchema(search=query)
|
UserFilterSchema(search=query)
|
||||||
.filter(User.objects.viewable_by(self.request.user))
|
.filter(User.objects.viewable_by(self.request.user))
|
||||||
.order_by(F("last_login").desc(nulls_last=True))
|
.order_by(F("last_login").desc(nulls_last=True))
|
||||||
.distinct()
|
|
||||||
)
|
)
|
||||||
clubs = list(Club.objects.filter(name__icontains=query)[:5])
|
clubs = list(Club.objects.filter(name__icontains=query)[:5])
|
||||||
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
|
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
|
||||||
|
|||||||
@@ -28,23 +28,18 @@ export class ProductAjaxSelect extends AjaxSelect {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// In the context in which this method is called, `this` might be shadowed
|
private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
|
||||||
// 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;
|
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||||
return `<div class="select-item">
|
return `<div class="select-item">
|
||||||
<span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span>
|
<span class="select-item-text">${this.getName(item, sanitize)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||||
return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`;
|
return `<span>${this.getName(item, sanitize)}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
<div id="student_card_form">
|
<div id="student_card_form">
|
||||||
<form
|
<form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
|
||||||
hx-post="{{ action }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-target="#student_card_form"
|
|
||||||
hx-disable="input[type='submit']"
|
|
||||||
>
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
|
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
|
||||||
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ C'est une technologie simple et puissante qui se veut comme le jQuery du web mod
|
|||||||
|
|
||||||
### Htmx
|
### Htmx
|
||||||
|
|
||||||
[Site officiel](https://four.htmx.org/)
|
[Site officiel](https://htmx.org/)
|
||||||
|
|
||||||
En plus de AlpineJS, l’interactivité sur le site est augmentée via Htmx.
|
En plus de AlpineJS, l’interactivité sur le site est augmentée via Htmx.
|
||||||
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
|
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
## 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-post="{{ url("election:apply_result", election_id=form.election.id) }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-target="#apply-election-result-fragment"
|
hx-target="#apply-election-result-fragment"
|
||||||
hx-disable="find input[type='submit']"
|
hx-disabled-elt="find input[type='submit']"
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form }}
|
{{ form }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-04 11:24+0200\n"
|
"POT-Creation-Date: 2026-08-21 14:10+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"
|
||||||
@@ -2049,10 +2049,6 @@ msgstr "403, Non autorisé"
|
|||||||
msgid "404, Not Found"
|
msgid "404, Not Found"
|
||||||
msgstr "404. Non trouvé"
|
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
|
#: core/templates/core/500.jinja
|
||||||
msgid "500, Server Error"
|
msgid "500, Server Error"
|
||||||
msgstr "500, Erreur Serveur"
|
msgstr "500, Erreur Serveur"
|
||||||
@@ -5093,10 +5089,6 @@ msgstr "signalant"
|
|||||||
msgid "A guide of courses available at UTBM."
|
msgid "A guide of courses available at UTBM."
|
||||||
msgstr "Un guide de tous les cours disponibles à l'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
|
#: pedagogy/templates/pedagogy/guide.jinja
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(display_name)s"
|
msgid "%(display_name)s"
|
||||||
@@ -5766,18 +5758,6 @@ msgstr "fin de la cotisation"
|
|||||||
msgid "location"
|
msgid "location"
|
||||||
msgstr "lieu"
|
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
|
#: subscription/models.py
|
||||||
msgid "You can not subscribe many time for the same period"
|
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"
|
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ nav:
|
|||||||
- API:
|
- API:
|
||||||
- Développement: tutorial/api/dev.md
|
- Développement: tutorial/api/dev.md
|
||||||
- Connexion à l'API: tutorial/api/connect.md
|
- Connexion à l'API: tutorial/api/connect.md
|
||||||
- Markdown AE: tutorial/markdown.md
|
|
||||||
- Etransactions: tutorial/etransaction.md
|
- Etransactions: tutorial/etransaction.md
|
||||||
- How-to:
|
- How-to:
|
||||||
- L'ORM de Django: howto/querysets.md
|
- L'ORM de Django: howto/querysets.md
|
||||||
|
|||||||
Generated
+5
-17
@@ -9,7 +9,6 @@
|
|||||||
"version": "3",
|
"version": "3",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ae_utbm/aemark": "^0.1.3",
|
|
||||||
"@alpinejs/sort": "^3.16.2",
|
"@alpinejs/sort": "^3.16.2",
|
||||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||||
"@floating-ui/dom": "^1.8.0",
|
"@floating-ui/dom": "^1.8.0",
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
"easymde": "^2.21.0",
|
"easymde": "^2.21.0",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"htmx.org": "^4.0.0",
|
"htmx.org": "^2.0.10",
|
||||||
"js-cookie": "^3.0.8",
|
"js-cookie": "^3.0.8",
|
||||||
"lit-html": "^3.3.3",
|
"lit-html": "^3.3.3",
|
||||||
"native-file-system-adapter": "^3.0.1",
|
"native-file-system-adapter": "^3.0.1",
|
||||||
@@ -57,11 +56,6 @@
|
|||||||
"vite": "^8.2.2"
|
"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": {
|
"node_modules/@alpinejs/sort": {
|
||||||
"version": "3.16.2",
|
"version": "3.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.16.2.tgz",
|
||||||
@@ -3807,16 +3801,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/htmx.org": {
|
"node_modules/htmx.org": {
|
||||||
"version": "4.0.0",
|
"version": "2.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
|
||||||
"integrity": "sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==",
|
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
|
||||||
"license": "BSD-0-Clause",
|
"license": "0BSD"
|
||||||
"workspaces": [
|
|
||||||
"ext/*"
|
|
||||||
],
|
|
||||||
"bin": {
|
|
||||||
"upgrade-check": "dist/scripts/upgrade-check.js"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/ical.js": {
|
"node_modules/ical.js": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
|
|||||||
+2
-3
@@ -34,13 +34,12 @@
|
|||||||
"@types/cytoscape-klay": "^3.1.5",
|
"@types/cytoscape-klay": "^3.1.5",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"@typescript/native": "npm:typescript@^7.0.2",
|
|
||||||
"rollup-plugin-visualizer": "^7.1.1",
|
"rollup-plugin-visualizer": "^7.1.1",
|
||||||
|
"@typescript/native": "npm:typescript@^7.0.2",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ae_utbm/aemark": "^0.1.3",
|
|
||||||
"@alpinejs/sort": "^3.16.2",
|
"@alpinejs/sort": "^3.16.2",
|
||||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||||
"@floating-ui/dom": "^1.8.0",
|
"@floating-ui/dom": "^1.8.0",
|
||||||
@@ -62,7 +61,7 @@
|
|||||||
"easymde": "^2.21.0",
|
"easymde": "^2.21.0",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"htmx.org": "^4.0.0",
|
"htmx.org": "^2.0.10",
|
||||||
"js-cookie": "^3.0.8",
|
"js-cookie": "^3.0.8",
|
||||||
"lit-html": "^3.3.3",
|
"lit-html": "^3.3.3",
|
||||||
"native-file-system-adapter": "^3.0.1",
|
"native-file-system-adapter": "^3.0.1",
|
||||||
|
|||||||
+49
-35
@@ -1,5 +1,9 @@
|
|||||||
import { getCurrentUrlParams, updateQueryString } from "#core:utils/history";
|
import {
|
||||||
import { type SimpleUeSchema, ueFetchUeList } from "#openapi";
|
getCurrentUrlParams,
|
||||||
|
History,
|
||||||
|
updateQueryString,
|
||||||
|
} from "#core:utils/history.ts";
|
||||||
|
import { ueFetchUeList } from "#openapi";
|
||||||
|
|
||||||
const pageDefault = 1;
|
const pageDefault = 1;
|
||||||
const pageSizeDefault = 100;
|
const pageSizeDefault = 100;
|
||||||
@@ -8,42 +12,38 @@ document.addEventListener("alpine:init", () => {
|
|||||||
Alpine.data("ue_search", () => ({
|
Alpine.data("ue_search", () => ({
|
||||||
ues: {
|
ues: {
|
||||||
count: 0,
|
count: 0,
|
||||||
next: null as string | null,
|
next: null,
|
||||||
previous: null as string | null,
|
previous: null,
|
||||||
results: [] as SimpleUeSchema[],
|
results: [],
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
page: pageDefault,
|
page: pageDefault,
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
page_size: pageSizeDefault,
|
page_size: pageSizeDefault,
|
||||||
search: "",
|
search: "",
|
||||||
department: [] as string[],
|
department: [],
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
credit_type: [] as string[],
|
credit_type: [],
|
||||||
semester: [] as string[],
|
semester: [],
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
to_change: [] as { param: string; value: string }[],
|
to_change: [],
|
||||||
|
pushstate: History.Push,
|
||||||
|
|
||||||
// dummy implementation to make TS happy.
|
update: undefined,
|
||||||
// The real function is initialized in init
|
|
||||||
update: () => {
|
|
||||||
console.warn("Update not yet initialized");
|
|
||||||
},
|
|
||||||
|
|
||||||
initializeArgs() {
|
initializeArgs() {
|
||||||
const url = getCurrentUrlParams();
|
const url = getCurrentUrlParams();
|
||||||
this.page = Number.parseInt(url.get("page") || pageDefault.toString(), 10);
|
this.pushstate = History.Replace;
|
||||||
this.page_size = Number.parseInt(
|
|
||||||
url.get("page_size") || pageSizeDefault.toString(),
|
this.page = Number.parseInt(url.get("page"), 10) || pageDefault;
|
||||||
10,
|
this.page_size = Number.parseInt(url.get("page_size"), 10) || pageSizeDefault;
|
||||||
);
|
|
||||||
this.search = url.get("search") || "";
|
this.search = url.get("search") || "";
|
||||||
this.department = url.getAll("department");
|
this.department = url.getAll("department");
|
||||||
this.credit_type = url.getAll("credit_type");
|
this.credit_type = url.getAll("credit_type");
|
||||||
/* The semester is easier to use on the backend as an enum (spring/autumn/both/none)
|
/* 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]).
|
and easier to use on the frontend as an array ([spring, autumn]).
|
||||||
Thus there is some conversion involved when both communicate together */
|
Thus there is some conversion involved when both communicate together */
|
||||||
this.semester = url.get("semester")?.split("_AND_") || [];
|
this.semester = url.has("semester") ? url.get("semester").split("_AND_") : [];
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
},
|
},
|
||||||
@@ -51,11 +51,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async init() {
|
async init() {
|
||||||
this.update = Alpine.debounce(async () => {
|
this.update = Alpine.debounce(async () => {
|
||||||
/* Create the whole url before changing everything all at once */
|
/* Create the whole url before changing everything all at once */
|
||||||
for (const val of this.to_change) {
|
const first = this.to_change.shift();
|
||||||
updateQueryString(val.param, val.value);
|
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);
|
||||||
}
|
}
|
||||||
|
updateQueryString(first.param, first.value, this.pushstate, url);
|
||||||
await this.fetchData(); /* reload data on form change */
|
await this.fetchData(); /* reload data on form change */
|
||||||
this.to_change = [];
|
this.to_change = [];
|
||||||
|
this.pushstate = History.Push;
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
const searchParams = ["search", "department", "credit_type", "semester"];
|
const searchParams = ["search", "department", "credit_type", "semester"];
|
||||||
@@ -63,37 +67,47 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
for (const param of searchParams) {
|
for (const param of searchParams) {
|
||||||
this.$watch(param, () => {
|
this.$watch(param, () => {
|
||||||
|
if (this.pushstate !== History.Push) {
|
||||||
|
/* This means that we are doing a mass param edit */
|
||||||
|
return;
|
||||||
|
}
|
||||||
/* Reset pagination on search */
|
/* Reset pagination on search */
|
||||||
this.page = pageDefault;
|
this.page = pageDefault;
|
||||||
this.page_size = pageSizeDefault;
|
this.page_size = pageSizeDefault;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const param of searchParams.concat(paginationParams)) {
|
for (const param of searchParams.concat(paginationParams)) {
|
||||||
this.$watch(param, (value: string) => {
|
this.$watch(param, (value) => {
|
||||||
this.to_change.push({ param: param, value: value });
|
this.to_change.push({ param: param, value: value });
|
||||||
this.update();
|
this.update();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
this.initializeArgs();
|
||||||
|
});
|
||||||
this.initializeArgs();
|
this.initializeArgs();
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchData() {
|
async fetchData() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
const args = {
|
||||||
const res = await ueFetchUeList({
|
|
||||||
query: {
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
page_size: this.page_size,
|
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,
|
for (const [param, value] of new URL(
|
||||||
semester: this.semester.length > 0 ? this.semester : undefined,
|
window.location.href,
|
||||||
department: this.department.length > 0 ? this.department : undefined,
|
).searchParams.entries()) {
|
||||||
search: this.search || undefined,
|
// Deal with array type params
|
||||||
},
|
if (["credit_type", "department", "semester"].includes(param)) {
|
||||||
});
|
if (args[param] === undefined) {
|
||||||
if (res.data !== undefined) {
|
args[param] = [];
|
||||||
this.ues = res.data;
|
|
||||||
}
|
}
|
||||||
|
args[param].push(value);
|
||||||
|
} else {
|
||||||
|
args[param] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.ues = (await ueFetchUeList({ query: args })).data;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@
|
|||||||
x-model.debounce.500ms="search"
|
x-model.debounce.500ms="search"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<input type="checkbox" class="switch" name="hide_closed_ues" id="hide-closed-ues">
|
||||||
|
<label for="hide-closed-ues">{% trans %}Show closed UEs{% endtrans %}</label>
|
||||||
|
</fieldset>
|
||||||
<div class="row gap-3x margin-bottom radio-guide">
|
<div class="row gap-3x margin-bottom radio-guide">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{% set departments = [
|
{% set departments = [
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -64,14 +64,6 @@ class Subscription(models.Model):
|
|||||||
max_length=20,
|
max_length=20,
|
||||||
verbose_name=_("location"),
|
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:
|
class Meta:
|
||||||
ordering = ["subscription_start"]
|
ordering = ["subscription_start"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<form
|
<form
|
||||||
hx-post="{{ url("subscription:fragment-existing-user") }}"
|
hx-post="{{ url("subscription:fragment-existing-user") }}"
|
||||||
hx-target="this"
|
hx-target="this"
|
||||||
hx-disable="find input[type='submit']"
|
hx-disabled-elt="find input[type='submit']"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<form
|
<form
|
||||||
hx-post="{{ url("subscription:fragment-new-user") }}"
|
hx-post="{{ url("subscription:fragment-new-user") }}"
|
||||||
hx-target="this"
|
hx-target="this"
|
||||||
hx-disable="find input[type='submit']"
|
hx-disabled-elt="find input[type='submit']"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import html2canvas from "html2canvas";
|
import html2canvas from "html2canvas";
|
||||||
|
|
||||||
// see https://regex101.com/r/QHSaPM/3
|
// see https://regex101.com/r/QHSaPM/2
|
||||||
const TIMETABLE_ROW_RE: RegExp =
|
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
|
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\tCM1\t\tlundi\t10:15\t12:15\t1\tPrésentiel\tA101
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# Create your views here.
|
# Create your views here.
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from core.auth.mixins import FormerSubscriberMixin
|
||||||
|
|
||||||
class GeneratorView(TemplateView):
|
|
||||||
|
class GeneratorView(FormerSubscriberMixin, TemplateView):
|
||||||
template_name = "timetable/generator.jinja"
|
template_name = "timetable/generator.jinja"
|
||||||
|
|||||||
Reference in New Issue
Block a user