Compare commits

..
1 Commits
Author SHA1 Message Date
imperosol 5177427549 adapt pedagogy to new UTBM UE API 2026-07-25 11:45:19 +02:00
48 changed files with 383 additions and 502 deletions
@@ -1,6 +1,7 @@
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
@@ -9,7 +10,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string) {
protected async search(query: string): Promise<TomOption[]> {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
@@ -1,9 +1,4 @@
import {
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
@@ -11,8 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -24,11 +19,11 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar!: Calendar;
private locale? = "en";
private canModerate? = false;
private canDelete? = false;
private helpUrl? = "";
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
// Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules
@@ -40,12 +35,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
this.canDelete = newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
@@ -101,7 +94,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop() as string,
.pop(),
10,
);
}
@@ -166,7 +159,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents();
}
async getEventSources(): Promise<EventInput[]> {
async getEventSources() {
const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url
// We rely on the fact that the event url is always the same
@@ -180,14 +173,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
};
return [
{
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
url: `${await makeUrl(calendarCalendarInternal)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
url: `${await makeUrl(calendarCalendarUnpublished)}`,
format: "ics",
color: "red",
className: "unpublished",
@@ -220,7 +213,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)}
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span>
</div>
`;
@@ -258,7 +251,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) {
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")}
@@ -345,9 +338,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied");
}
button.setAttribute("tooltip", gettext("Link copied"));
await navigator.clipboard.writeText(
navigator.clipboard.writeText(
new URL(
await makeUrl(calendarCalendarInternal as GenericEndpoint),
await makeUrl(calendarCalendarInternal),
window.location.origin,
).toString(),
);
@@ -1,9 +1,4 @@
import {
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript
@@ -69,8 +64,7 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
return response.data.count;
},
weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & {
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
};
}
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,11 +32,6 @@ document.addEventListener("alpine:init", () => {
page_size: this.pageSize,
},
});
if (response.response === undefined || response.data === undefined) {
// response may be undefined, because error may be
// from building the request object itself or from a network error
return;
}
if (response.response.status === 404) {
this.hasNext = false;
} else if (response.data.next === null) {
@@ -49,7 +44,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false;
},
groupedDates(): Record<string, ParsedNewsDateSchema[]> {
groupedDates(): Record<string, NewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
+2 -3
View File
@@ -1,4 +1,4 @@
from typing import Annotated, Literal
from typing import Annotated, Any, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
@@ -26,7 +26,6 @@ from core.schemas import (
UserFilterSchema,
UserProfileSchema,
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@@ -46,7 +45,7 @@ class UploadController(ControllerBase):
"/image",
response={
200: UploadedFileSchema,
422: ValidationErrorSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
-10
View File
@@ -19,16 +19,6 @@ from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class ValidationErrorSchema(Schema):
class ValidationErrorItem(Schema):
loc: list[str | int]
msg: str
type: str
ctx: dict[str, str]
detail: list[ValidationErrorItem]
class UploadedImage(UploadedFile):
@classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater<string>(expression);
const getMaxChoices = evaluateLater(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
});
}
effect(() => {
getMaxChoices((value) => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
+6 -12
View File
@@ -43,19 +43,13 @@ polyfillCountryFlagEmojis();
/**
* HTMX
*/
document.body.addEventListener(
"htmx:beforeRequest" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = true;
},
);
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener(
"htmx:beforeSwap" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = null;
},
);
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });
@@ -4,9 +4,9 @@ import type {
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { inheritHtmlElement } from "#core:utils/web-components";
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max",
"min-characters-for-search",
];
public widget!: TomSelect;
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback(
name: string,
_oldValue: string,
newValue: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay ?? undefined,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[];
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
/**
* A cache of researches that have been made using this input.
@@ -1,12 +1,11 @@
// @ts-expect-error TS2882
import "tom-select/dist/css/tom-select.default.css";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type GroupSchema,
groupSearchGroup,
@@ -1,13 +1,11 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css";
// 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 EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
markdownRenderMarkdown,
type UploadUploadImageErrors,
@@ -27,11 +25,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file,
},
});
if (response.response !== undefined && !response.response.ok) {
if (response?.response.status === 422) {
if (!response.response.ok) {
if (response.response.status === 422) {
onError(
(response.error as UploadUploadImageErrors[422]).detail
.map((err) => err.ctx.error)
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.join(" ; "),
);
} else if (response.response.status === 403) {
@@ -41,10 +39,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}
return;
}
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url)
@@ -64,18 +58,21 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText, preview) => {
previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce(() => {
const func = async () => {
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func().then();
func(plainText, preview);
return null;
}, 300)();
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
@@ -225,11 +222,9 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
const submits: HTMLInputElement[] = Array.from(
(textarea.closest("form") as HTMLFormElement).querySelectorAll(
'input[type="submit"]',
),
textarea.closest("form").querySelectorAll('input[type="submit"]'),
);
const parentDiv = textarea.parentElement?.parentElement as HTMLElement;
const parentDiv = textarea.parentElement.parentElement;
function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string
@@ -254,7 +249,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
};
@registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() {
super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components";
} from "#core:utils/web-components.ts";
/**
* ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild as ChildNode);
this.removeChild(this.lastChild);
}
}
@@ -130,7 +130,7 @@ startObserver(observer);
export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`;
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
}
}
@@ -142,6 +142,6 @@ export class LinkOnce extends elementOnce("link") {
export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`;
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
}
}
@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") {
@@ -26,11 +26,9 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
ndef.addEventListener("reading", (event) => {
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan");
this.node.value = (event as NDEFReadingEvent).serialNumber
.replace(/:/g, "")
.toUpperCase();
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button");
this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components";
import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab")
export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
}
if (name === "title") {
this.description = newValue ?? "";
this.description = newValue;
}
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
}
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement {
private node!: HTMLDivElement;
private node: HTMLDivElement;
connectedCallback() {
this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow");
this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event) => {
const target = (event as CustomEvent).detail as Tab;
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
const target = event.detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) {
tab.setActive(false);
@@ -26,23 +26,19 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
(
this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
).setAttribute(":value", "nbForms");
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild as Element;
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
@@ -63,7 +59,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form = this.formContainer.children[i];
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
+3 -6
View File
@@ -1,5 +1,5 @@
function showMenu() {
const navbar = document.getElementById("navbar-content") as HTMLElement;
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
}
@@ -20,12 +20,9 @@ function navbarInit() {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event) => {
item.addEventListener("click", (event: MouseEvent) => {
// Don't close when clicking on desktop mode
if (
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
(event as MouseEvent).detail === 0
) {
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
return;
}
@@ -1,4 +1,3 @@
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
import clip from "@arendjr/text-clipper";
/*
+7 -8
View File
@@ -70,16 +70,15 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip") as string;
tooltip.innerText = element.getAttribute("tooltip");
for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] },
]) {
const populated = attributes.default;
const attr = element.getAttribute(attributes.src);
if (attr !== null) {
populated.push(...attr.split(" "));
if (element.hasAttribute(attributes.src)) {
populated.push(...element.getAttribute(attributes.src).split(" "));
}
tooltip.setAttribute(attributes.dst, populated.join(" "));
}
@@ -94,7 +93,7 @@ function getTooltip(element: HTMLElement) {
return tooltip;
}
function tooltipMouseover(event: Event) {
function tooltipMouseover(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -112,7 +111,7 @@ function tooltipMouseover(event: Event) {
});
}
function tooltipMouseout(event: Event) {
function tooltipMouseout(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -142,7 +141,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
}
} else if (tooltips.has(target)) {
// Remove corresponding tooltip
tooltips.get(target)?.remove();
tooltips.get(target).remove();
tooltips.delete(target);
}
}
@@ -164,7 +163,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue;
}
if (tooltips.has(target)) {
tooltips.get(target)?.remove();
tooltips.get(target).remove();
tooltips.delete(target);
}
}
+23 -18
View File
@@ -1,13 +1,20 @@
import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() {
return window.innerWidth < 500;
@@ -20,8 +27,10 @@ async function getGraphData(
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
// biome-ignore lint/style/useNamingConvention: api is snake_case
path: { user_id: userId },
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
@@ -30,10 +39,6 @@ async function getGraphData(
},
})
).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [
...data.users.map((user) => {
return { data: user };
@@ -150,7 +155,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank")?.focus();
window.open(el.data().profile_url, "_blank").focus();
},
},
@@ -190,12 +195,12 @@ document.addEventListener("alpine:init", () => {
godfathersDepth: 0,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as unknown as cytoscape.Core,
graphData: {} as GraphData,
graph: undefined as cytoscape.Core,
graphData: {},
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop) as string, 10);
const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
@@ -218,8 +223,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch();
});
}
this.$watch("reverse", async (newValue, _oldValue) => {
updateQueryString("reverse", newValue.toString(), History.Replace);
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
@@ -254,9 +259,9 @@ document.addEventListener("alpine:init", () => {
},
async reverseGraph() {
this.graph
.elements()
.positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage {
public open: boolean;
public success!: boolean;
public success: boolean;
public content: string;
private timeoutId: number | null;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
+5 -8
View File
@@ -37,10 +37,6 @@ export const paginated = async <T>(
queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results;
const nbElements = firstPage.count;
@@ -49,9 +45,9 @@ export const paginated = async <T>(
if (nbPages > 1) {
const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
const nextPage = structuredClone(queryParams);
nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data?.results ?? []));
promises.push(endpoint(nextPage).then((res) => res.data.results));
}
results.push(...(await Promise.all(promises)).flat());
}
@@ -65,7 +61,8 @@ interface Request extends TDataShape {
interface InterceptorOptions {
url: string;
}
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -74,7 +71,7 @@ export type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/
export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = "";
const interceptor = (_request: Request, options: InterceptorOptions) => {
const interceptor = (_request: undefined, options: InterceptorOptions) => {
url = options.url;
throw new Error("We don't want to send the request");
};
+9 -12
View File
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
titleRow?: readonly string[];
}
function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
const path = key.split(".");
let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as keyof T];
for (const node of path) {
if (res === undefined) {
if (res === null) {
break;
}
res = res[node] as { [key: string]: unknown } | undefined;
res = res[node];
}
return res;
}
@@ -29,21 +29,18 @@ function sanitizeCell(content: string): string {
}
export const csv = {
stringify: <T extends { [key: string]: unknown }>(
objs: T[],
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
const columns = options.columns;
const content = objs
.map((obj) => {
return (columns ?? [])
return columns
.map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString());
})
.join(",");
})
.join("\n");
if (!options?.titleRow) {
if (!options.titleRow) {
return content;
}
const firstRow = options.titleRow.map(sanitizeCell).join(",");
+2 -3
View File
@@ -29,7 +29,6 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement {
readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K];
}
@@ -48,8 +47,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
readonly initializedAttribute = "component-initialized";
node!: HTMLElementTagNameMap[K];
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type CounterSchema,
counterSearchCounter,
@@ -28,18 +28,14 @@ export class ProductAjaxSelect extends AjaxSelect {
return [];
}
private 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">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${this.getName(item, sanitize)}</span>`;
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
}
}
@@ -48,7 +44,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[] | null;
private productTypes = null as ProductTypeSchema[];
protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types
@@ -56,7 +52,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
// Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || [];
this.productTypes = (await producttypeFetchAll()).data || null;
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
@@ -59,7 +59,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return onOptionSelect.call(
this.widget,
evt,
this.widget.getOption(value, true) as HTMLElement,
this.widget.getOption(value, true),
);
},
);
@@ -70,15 +70,12 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
...super.tomSelectSettings(),
openOnFocus: false,
// We make searching on exact code matching a higher priority
// We need to manually set weights, or it results on an inconsistent
// We need to manually set weights or it results on an inconsistent
// behavior between production and development environment
//
// SearchField can be an object array (according to the docs),
// but it is unproperly typed, so we must force-cast to trick TS
searchField: [
{ field: "code", weight: 2 },
{ field: "text", weight: 0.5 },
] as unknown as string[],
],
};
}
}
@@ -1,14 +1,9 @@
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import type { ClubAjaxSelect } from "#club:club/components/ajax-select-index";
import type { NestedKeyOf } from "#core:types/nested-key";
import { type PaginatedRequest, paginated } from "#core:utils/api";
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type {
CounterAjaxSelect,
ProductTypeAjaxSelect,
} from "#counter:counter/components/ajax-select-index";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
@@ -56,8 +51,6 @@ const csvColumnTitles = [
gettext("archived"),
];
type ProductStatus = "active" | "archived" | "both";
document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({
loading: false,
@@ -66,7 +59,8 @@ document.addEventListener("alpine:init", () => {
/** Total number of elements corresponding to the current query. */
nbPages: 0,
productStatus: "" as ProductStatus,
productStatus: "" as "active" | "archived" | "both",
search: "",
productTypes: [] as string[],
clubs: [] as string[],
@@ -77,18 +71,16 @@ document.addEventListener("alpine:init", () => {
async init() {
const url = getCurrentUrlParams();
this.search = url.get("search") || "";
this.productStatus = (url.get("productStatus") ?? "active") as ProductStatus;
const productTypesWidget = (this.$refs.productTypesInput as ProductTypeAjaxSelect)
.widget as TomSelect;
this.productStatus = url.get("productStatus") ?? "active";
const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect;
productTypesWidget.on("change", (items: string[]) => {
this.productTypes = [...items];
});
const clubsWidget = (this.$refs.clubsInput as ClubAjaxSelect).widget as TomSelect;
const clubsWidget = this.$refs.clubsInput.widget as TomSelect;
clubsWidget.on("change", (items: string[]) => {
this.clubs = [...items];
});
const countersWidget = (this.$refs.countersInput as CounterAjaxSelect)
.widget as TomSelect;
const countersWidget = this.$refs.countersInput.widget as TomSelect;
countersWidget.on("change", (items: string[]) => {
this.counters = [...items];
});
@@ -135,9 +127,9 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes.map(Number.parseInt)],
club: [...this.clubs.map(Number.parseInt)],
counter: [...this.counters.map(Number.parseInt)],
product_type: [...this.productTypes],
club: [...this.clubs],
counter: [...this.counters],
},
};
},
@@ -149,10 +141,6 @@ document.addEventListener("alpine:init", () => {
this.loading = true;
const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options);
if (resp.data === undefined) {
console.error("Product search request failed");
return;
}
this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>(
(acc: GroupedProducts, curr: ProductSchema) => {
@@ -184,10 +172,7 @@ document.addEventListener("alpine:init", () => {
// If not, fetch them.
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(
productSearchProductsDetailed,
this.getQueryParams() as PaginatedRequest,
)
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();
// CSV cannot represent nested data
// so we create a row for each price of each product.
@@ -1,5 +1,5 @@
import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<HTMLLIElement>;
const getId = (elem: HTMLLIElement) =>
Number.parseInt(elem.getAttribute("x-sort:item") as string, 10);
Number.parseInt(elem.getAttribute("x-sort:item"), 10);
const query =
newPosition === 0
? { above: getId(productTypes.item(1)) }
@@ -32,10 +32,6 @@ document.addEventListener("alpine:init", () => {
path: { type_id: itemId },
query: query,
});
if (response.response === undefined) {
console.error("Product type reordering request failed");
return;
}
this.openAlertMessage(response.response);
this.loading = false;
},
-22
View File
@@ -886,28 +886,6 @@ class TestBarmanConnection(TestCase):
)
assert response.status_code == 403
def test_logout_then_login(self):
"""Test that the login is successful if it is after a previous logout.
This is a regression test for #1440
"""
self.client.post( # login
self.login_url, {"username": self.barman.username, "password": "plop"}
)
self.client.post( # logout
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
data={"user_id": self.barman.id},
)
response = self.client.post( # and re-login
self.login_url, {"username": self.barman.username, "password": "plop"}
)
assert response.status_code == 200
assert response.headers["HX-Redirect"] == self.detail_url
response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
assert self.barman in response.wsgi_request.barmen
@pytest.mark.django_db
def test_barman_timeout(client: Client):
-1
View File
@@ -61,7 +61,6 @@ class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
user = form.get_user()
perm = self.object.permanencies.create(user=user, start=timezone.now())
self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id)
self.request.session.modified = True
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.id}
)
@@ -61,10 +61,10 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id },
});
if (res.response?.ok) {
if (res.response.ok) {
this.data = res.data as Record<string, string>;
this.isCbAvailable = true;
} else if (res.response?.status === 410) {
} else if (res.response.status === 410) {
// The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry
-18
View File
@@ -48,7 +48,6 @@
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^26.1.1",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^6.0.3",
"vite": "^8.1.0"
@@ -2512,16 +2511,6 @@
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -4665,13 +4654,6 @@
"integrity": "sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==",
"license": "BSD-3-Clause"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
-1
View File
@@ -33,7 +33,6 @@
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^26.1.1",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^6.0.3",
"vite": "^8.1.0"
@@ -18,7 +18,12 @@ class Command(BaseCommand):
"Fetching UEs from the UTBM API.\n"
"This may take a few minutes to complete."
)
i = 0
for ue in client.fetch_ues():
if (i := (i + 1)) % 10 == 0:
self.stdout.write(f"{i} UEs processed")
# this is a N+1 query, but it isn't a problem,
# as fetching a UE from the UTBM API is taking most of the time anyway
db_ue = UE.objects.filter(code=ue.code).first()
if db_ue is None:
db_ue = UE(code=ue.code, author=root_user)
+34 -18
View File
@@ -5,12 +5,22 @@ from django.urls import reverse
from django.utils import html
from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic import AliasPath, BeforeValidator, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel
from pedagogy.models import UE
class FormationSchema(Schema):
model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
code: str
label: str = Field(alias="libelle")
FormationListSchema = TypeAdapter(list[FormationSchema])
class UtbmShortUeSchema(Schema):
"""Short representation of an UE in the UTBM API.
@@ -19,19 +29,17 @@ class UtbmShortUeSchema(Schema):
The UTBM API returns more data than that.
"""
model_config = ConfigDict(alias_generator=to_camel)
model_config = ConfigDict(validate_by_name=True)
code: str
code_formation: str
code_categorie: str | None
code_langue: str
ouvert_automne: bool
ouvert_printemps: bool
category: str = Field(alias="codeCategorie")
formation: FormationSchema
lang: str = Field(alias="codeLangue")
open_autumn: bool = Field(alias="ouvertAutomne")
open_spring: bool = Field(alias="ouvertPrintemps")
class WorkloadSchema(Schema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
code: Literal["TD", "TP", "CM", "THE", "TE"]
nbh: int
@@ -48,22 +56,30 @@ class SemesterUeState(Schema):
ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
class SyllabusItemSchema(Schema):
model_config = ConfigDict(validate_by_name=True)
label: str = Field(alias="libelle")
# We want only strings, but the UTBM API can return null,
# so convert null values to empty strings
value: Annotated[str, BeforeValidator(lambda x: x or "")] = Field(alias="valeur")
class UtbmFullUeSchema(Schema):
"""Long representation of an UE in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel)
model_config = ConfigDict(validate_by_name=True, alias_generator=to_camel)
code: str
departement: str = "NA"
libelle: str | None
objectifs: str | None
programme: str | None
acquisition_competences: str | None
acquisition_notions: str | None
langue: str
code_langue: str
label: str = Field(alias="libelle")
syllabus: list[SyllabusItemSchema] = Field(
default=[],
validation_alias=AliasPath("listeElementConstitutif", 0, "listeSaisieSyllabus"),
)
lang: str = Field(None, validation_alias=AliasPath("langueEnseignement", "code"))
credits_ects: int
activites: list[WorkloadSchema]
activites: list[WorkloadSchema] = Field(alias="listeActivite")
respo_automne: str | None = Field(
None, validation_alias=AliasPath("automne", "responsable")
)
+81 -36
View File
@@ -5,8 +5,16 @@ from typing import Iterator
import requests
from django.conf import settings
from django.utils.functional import cached_property
from pydantic import TypeAdapter
from pedagogy.schemas import ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema
from pedagogy.schemas import (
FormationSchema,
UeSchema,
UtbmFullUeSchema,
UtbmShortUeSchema,
)
FormationListSchema = TypeAdapter(list[FormationSchema])
class UtbmApiClient(requests.Session):
@@ -18,28 +26,44 @@ class UtbmApiClient(requests.Session):
@cached_property
def current_year(self) -> int:
"""Fetch from the API the latest existing year"""
url = f"{self.BASE_URL}/guides/fr"
response = self.get(url)
return response.json()[-1]["annee"]
url = f"{self.BASE_URL}/guide/"
response = self.get(url, {"langue": "fr"})
return max(i["annee"] for i in response.json())
def fetch_short_ues(
self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUeSchema]:
@cached_property
def formations(self) -> list[FormationSchema]:
response = self.get(
f"{self.BASE_URL}/formation/",
{"langue": "fr", "annee_univ": self.current_year, "typeFormation": "ING"},
)
return FormationListSchema.validate_json(response.text, by_alias=True)
def fetch_short_ues(self, year: int | None = None) -> list[UtbmShortUeSchema]:
"""Get the list of UEs in their short format from the UTBM API"""
if year is None:
year = self.current_year
if lang not in self._cache["short_ues"]:
self._cache["short_ues"][lang] = {}
if year not in self._cache["short_ues"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url)
ues = ShortUeList.validate_json(response.content)
self._cache["short_ues"][lang][year] = ues
return self._cache["short_ues"][lang][year]
if year not in self._cache["short_ues"]:
ues = []
for formation in self.formations:
response = self.get(
f"{self.BASE_URL}/ue/",
{
"langue": "fr",
"annee_univ": year,
"codeFormation": formation.code,
},
)
ues.extend(
[
UtbmShortUeSchema.model_validate({**ue, "formation": formation})
for ue in response.json()
if ue["codeCategorie"] is not None
]
)
self._cache["short_ues"][year] = ues
return self._cache["short_ues"][year]
def fetch_ues(
self, lang: str = "fr", year: int | None = None
) -> Iterator[UeSchema]:
def fetch_ues(self, year: int | None = None) -> Iterator[UeSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use.
Warning:
@@ -52,7 +76,7 @@ class UtbmApiClient(requests.Session):
"""
if year is None:
year = self.current_year
shorts_ues = self.fetch_short_ues(lang, year)
shorts_ues = self.fetch_short_ues(year)
# When UEs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch.
# We have no way in our db to link a UE to multiple formations,
@@ -65,27 +89,40 @@ class UtbmApiClient(requests.Session):
if ue.code not in unique_short_ues:
unique_short_ues[ue.code] = ue
for ue in unique_short_ues.values():
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}"
response = requests.get(ue_url)
if ue.lang.upper() != "FR":
continue
response = requests.get(
f"{self.BASE_URL}/ue/{ue.code}",
{
"langue": ue.lang,
"annee_univ": year,
"codeFormation": ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
yield make_clean_ue(ue, full_ue)
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None:
"""Find an UE from the UTBM API."""
# query the UE list
def find_ue(self, code: str, year: int | None = None) -> UeSchema | None:
"""Find a UE from the UTBM API."""
if not year:
year = self.current_year
# the UTBM API has no way to fetch a single short ue,
# and short ues contain infos that we need and are not
# in the full ue schema, so we must fetch everything.
short_ues = self.fetch_short_ues(lang, year)
short_ues = self.fetch_short_ues(year)
short_ue = next((ue for ue in short_ues if ue.code == code), None)
if short_ue is None:
return None
# get detailed information about the UE
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}"
response = requests.get(ue_url)
response = requests.get(
f"{self.BASE_URL}/ue/{code}",
{
"langue": "fr",
"annee_univ": year,
"codeFormation": short_ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
return make_clean_ue(short_ue, full_ue)
@@ -112,9 +149,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
"ED": "EDIM",
"AI": "GI",
"AM": "MC",
}.get(short_ue.code_formation, "NA")
}.get(short_ue.formation.code, "NA")
match short_ue.ouvert_printemps, short_ue.ouvert_automne:
match short_ue.open_spring, short_ue.open_autumn:
case True, True:
semester = "AUTUMN_AND_SPRING"
case True, False:
@@ -125,11 +162,11 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
semester = "CLOSED"
return UeSchema(
title=full_ue.libelle or "",
title=full_ue.label or "",
code=full_ue.code,
credit_type=short_ue.code_categorie or "FREE",
credit_type=short_ue.category or "FREE",
semester=semester,
language=short_ue.code_langue.upper(),
language=short_ue.lang.upper(),
credits=full_ue.credits_ects,
department=department,
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60,
@@ -138,8 +175,16 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60,
manager=full_ue.respo_automne or full_ue.respo_printemps or "",
objectives=full_ue.objectifs or "",
program=full_ue.programme or "",
skills=full_ue.acquisition_competences or "",
key_concepts=full_ue.acquisition_notions or "",
objectives=next(
(i.value for i in full_ue.syllabus if i.label == "Objectifs"), ""
),
program=next((i.value for i in full_ue.syllabus if i.label == "Programme"), ""),
skills=next(
(i.value for i in full_ue.syllabus if i.label == "Acquis d'apprentissage"),
"",
),
key_concepts=next(
(i.value for i in full_ue.syllabus if i.label == "Notions-clés"),
"",
),
)
+3 -3
View File
@@ -1,4 +1,4 @@
from typing import Literal
from typing import Any, Literal
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -20,7 +20,7 @@ from api.permissions import (
IsRoot,
)
from core.models import Notification, User
from core.schemas import UploadedImage, ValidationErrorSchema
from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import (
AlbumAutocompleteSchema,
@@ -106,7 +106,7 @@ class PicturesController(ControllerBase):
response={
200: None,
409: dict[Literal["detail"], dict[str, list[str]]],
422: ValidationErrorSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
},
url_name="upload_picture",
)
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select")
@@ -9,7 +9,7 @@ document.addEventListener("alpine:init", () => {
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress as HTMLProgressElement;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.downloadPictures.length;
@@ -31,11 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all(
this.downloadPictures.map((p: PictureSchema) => {
const dateStr = (p.date as string).replace(/[:-]/g, "_");
const imgName = `${p.album.name}/IMG_${p.id}_${dateStr}${p.name.slice(p.name.lastIndexOf("."))}`;
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date as string),
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
+8 -10
View File
@@ -57,16 +57,14 @@ document.addEventListener("alpine:init", () => {
async init() {
const pictures = await this.fetchPictures();
const groupedAlbums = Object.groupBy(pictures, (i) => i.album.id as number);
this.albums = Object.values(groupedAlbums as Record<number, PictureSchema[]>).map(
(pictures) => {
return {
id: pictures[0].album.id as number,
name: pictures[0].album.name,
pictures: pictures,
};
},
);
const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
return {
id: pictures[0].album.id,
name: pictures[0].album.name,
pictures: pictures,
};
});
this.albums.sort((a: Album, b: Album) => b.id - a.id);
const hash = document.location.hash.replace("#", "");
if (hash.startsWith("album-")) {
+36 -71
View File
@@ -1,5 +1,4 @@
import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
@@ -7,6 +6,7 @@ import {
type IdentifiedUserSchema,
type ModerationRequestSchema,
type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData,
picturesDeletePicture,
picturesFetchIdentifications,
@@ -15,7 +15,6 @@ import {
picturesIdentifyUsers,
picturesModeratePicture,
picturesRotatePicture,
type SimpleAlbumSchema,
type UserProfileSchema,
usersidentifiedDeleteRelation,
} from "#openapi";
@@ -24,8 +23,8 @@ import {
* A container for a picture with the users identified on it
* able to prefetch its data.
*/
class PictureWithIdentifications implements PictureSchema {
identifications?: IdentifiedUserSchema[];
class PictureWithIdentifications {
identifications: PicturesFetchIdentificationsResponse = null;
imageLoading = false;
identificationsLoading = false;
moderationLoading = false;
@@ -33,43 +32,10 @@ class PictureWithIdentifications implements PictureSchema {
compressedUrl: string = "";
thumbUrl: string = "";
fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = [];
owner: UserProfileSchema;
// biome-ignore-start lint/style/useNamingConvention: PictureSchema has CamelCase properties
sas_url: string;
full_size_url: string;
compressed_url: string;
thumb_url: string;
album: SimpleAlbumSchema;
report_url: string;
edit_url: string;
name: string;
date?: string | undefined;
updated_at: string;
size?: number | undefined;
is_moderated?: boolean | undefined;
asked_for_removal?: boolean | undefined;
// biome-ignore-end lint/style/useNamingConvention: PictureSchema has CamelCase properties
moderationRequests: ModerationRequestSchema[] = null;
constructor(picture: PictureSchema) {
if (picture.id === null || picture.id === undefined) {
throw new Error("picture id expected a value");
}
this.owner = picture.owner;
this.sas_url = picture.sas_url;
this.full_size_url = picture.full_size_url;
this.compressed_url = picture.compressed_url;
this.thumb_url = picture.thumb_url;
this.album = picture.album;
this.report_url = picture.report_url;
this.edit_url = picture.edit_url;
this.name = picture.name;
this.date = picture.date;
this.updated_at = picture.updated_at;
this.size = picture.size;
this.is_moderated = picture.is_moderated;
this.asked_for_removal = picture.asked_for_removal;
this.id = picture.id;
Object.assign(this, picture);
this.compressedUrl = picture.compressed_url;
this.thumbUrl = picture.thumb_url;
this.fullSizeUrl = picture.full_size_url;
@@ -103,13 +69,12 @@ class PictureWithIdentifications implements PictureSchema {
return;
}
this.identificationsLoading = true;
this.identifications =
(
await picturesFetchIdentifications({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data ?? [];
this.identifications = (
await picturesFetchIdentifications({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data;
this.identificationsLoading = false;
}
@@ -123,13 +88,12 @@ class PictureWithIdentifications implements PictureSchema {
return;
}
this.moderationLoading = true;
this.moderationRequests =
(
await picturesFetchModerationRequests({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data ?? [];
this.moderationRequests = (
await picturesFetchModerationRequests({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data;
this.moderationLoading = false;
}
@@ -139,10 +103,6 @@ class PictureWithIdentifications implements PictureSchema {
// biome-ignore lint/style/useNamingConvention: api is snake case
path: { picture_id: this.id, direction: direction },
});
if (!res.data) {
console.error("The data o the rotated were expected, but nothing was returned");
return;
}
// urls returned by the api include a timestamp for cache busting
this.fullSizeUrl = res.data.full_size_url;
this.compressedUrl = res.data.compressed_url;
@@ -197,7 +157,7 @@ document.addEventListener("alpine:init", () => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null as number | null,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@@ -211,19 +171,19 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[],
} as unknown as PictureWithIdentifications,
},
/**
* The picture which will be displayed next if the user press the "next" button
**/
nextPicture: null as PictureWithIdentifications | null,
nextPicture: null as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "previous" button
**/
previousPicture: null as PictureWithIdentifications | null,
previousPicture: null as PictureWithIdentifications,
/**
* The select2 component used to identify users
**/
selector: undefined as UserAjaxSelect | undefined,
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/
@@ -241,18 +201,24 @@ document.addEventListener("alpine:init", () => {
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search as UserAjaxSelect;
this.selector.setFilter((users: TomOption[]) => {
this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
return users.filter((user) => !ids.includes(user.id));
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
}
return resp;
});
this.currentPicture = this.pictures.find(
(i) => i.id === config.firstPictureId,
) as PictureWithIdentifications;
(i: PictureSchema) => i.id === config.firstPictureId,
);
this.$watch(
"currentPicture",
(current: PictureSchema, previous: PictureSchema) => {
@@ -270,7 +236,7 @@ document.addEventListener("alpine:init", () => {
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
) as PictureWithIdentifications;
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
@@ -345,15 +311,14 @@ document.addEventListener("alpine:init", () => {
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = (this.nextPicture ||
this.previousPicture) as PictureWithIdentifications;
this.currentPicture = this.nextPicture || this.previousPicture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = (this.selector as UserAjaxSelect).widget;
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
+1 -1
View File
@@ -493,7 +493,7 @@ SITH_LOG_OPERATION_TYPE = [
("REFILLING_DELETION", _("Refilling deletion")),
]
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
SITH_PEDAGOGY_UTBM_API = "https://api.utbm.fr/offre-formation/public"
# Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -1,11 +1,10 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { userFetchUser } from "#openapi";
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
selectedUser: "",
profileFragment: "",
dateOfBirth: "" as string | null,
dateOfBirth: "",
dateOfBirthHidden: true,
init() {
@@ -15,9 +14,7 @@ Alpine.data("existing_user_subscription_form", () => ({
this.$nextTick(() => {
// Force to detect the initial value
this.selectedUser = (
this.$refs.userSelect as UserAjaxSelect
).widget.getValue() as string;
this.selectedUser = this.$refs.userSelect.widget.getValue();
});
},
@@ -34,15 +31,11 @@ Alpine.data("existing_user_subscription_form", () => ({
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
if (userInfos.data === undefined) {
console.error("User infos request failed");
return;
}
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
this.dateOfBirth = userInfos.data.date_of_birth ?? null;
this.dateOfBirth = userInfos.data.date_of_birth;
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
this.loading = false;
},
@@ -17,10 +17,7 @@ function getRandomColorUniq(list: string[]) {
}
return color;
}
type Rgb = { r: number; g: number; b: number };
function hexToRgb(hex: string): Rgb | null {
function hexToRgb(hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => {
@@ -40,29 +37,32 @@ function hexToRgb(hex: string): Rgb | null {
document.addEventListener("DOMContentLoaded", () => {
const ctx = (document.getElementById("statsChart") as HTMLCanvasElement).getContext(
"2d",
) as CanvasRenderingContext2D;
);
const labels: string[] = [];
const total: string[] = [];
const colorStrings: string[] = [];
const colors: string[] = [];
const colorsDimmed: string[] = [];
for (const element of Array.from(document.getElementsByClassName("types"))) {
labels.push(element.childNodes[0].textContent as string);
labels.push(element.childNodes[0].textContent);
}
for (const element of Array.from(document.getElementsByClassName("total"))) {
total.push(element.childNodes[0].childNodes[0].textContent as string);
total.push(element.childNodes[0].childNodes[0].textContent);
}
for (const _ of labels) {
colorStrings.push(getRandomColorUniq(colorStrings));
}
const colors = colorStrings.map(hexToRgb) as Rgb[];
for (const color of colors) {
colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`);
colors.push(getRandomColorUniq(colors));
}
for (const color of colors) {
colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`);
for (const element of colors) {
const rgbColor = hexToRgb(element);
colorsDimmed.push(`rgba(${rgbColor.r}, ${rgbColor.g}, ${rgbColor.b}, 0.2)`);
}
for (const element of colors) {
const rgbColorDimmed = hexToRgb(element);
colorsDimmed.push(
`rgba(${rgbColorDimmed.r}, ${rgbColorDimmed.g}, ${rgbColorDimmed.b}, 0.2)`,
);
}
new Chart(ctx, {
@@ -71,11 +71,10 @@ document.addEventListener("DOMContentLoaded", () => {
labels: labels,
datasets: [
{
label: document.getElementById("graphLabel")?.childNodes[0]
.textContent as string,
label: document.getElementById("graphLabel").childNodes[0].textContent,
data: total,
backgroundColor: colorsDimmed,
borderColor: colorStrings,
borderColor: colors,
borderWidth: 1,
},
],
@@ -58,7 +58,7 @@ function parseSlots(s: string): TimetableSlot[] {
.filter((s: string) => s.length > 0)
.map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed?.groups) {
if (!parsed) {
throw new Error(`Couldn't parse row ${row}`);
}
const [startHour, startMin] = parsed.groups.startHour
@@ -173,7 +173,7 @@ document.addEventListener("alpine:init", () => {
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const img = (await html2canvas(elem)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
-27
View File
@@ -1,27 +0,0 @@
{
"exclude": ["staticfiles/generated/**/*", "*.mts", "node_modules/**/*"],
"compilerOptions": {
"outDir": "./staticfiles/generated/bundled/",
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"types": ["alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],
"#com:*": ["./com/static/bundled/*"],
"#club:*": ["./club/static/bundled/*"]
}
}
}
+22 -4
View File
@@ -1,6 +1,24 @@
{
"references": [
{"path": "tsconfig.bundled.json"},
{"path": "tsconfig.node.json"}
],
"compilerOptions": {
"outDir": "./staticfiles/generated/bundled/",
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],
"#com:*": ["./com/static/bundled/*"]
}
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"include": ["vite.config.*"],
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"types": ["node"],
"emitDeclarationOnly": true,
"composite": true
}
}
+5 -3
View File
@@ -8,7 +8,7 @@ import {
type Rollup,
type UserConfig,
} from "vite";
import { compilerOptions } from "./tsconfig.bundled.json";
import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const collectedFiles = glob.sync(
@@ -20,7 +20,7 @@ const collectedFiles = glob.sync(
**/
function getAliases(): AliasOptions {
const aliases: AliasOptions = {};
for (const [key, value] of Object.entries(compilerOptions.paths)) {
for (const [key, value] of Object.entries(tsconfig.compilerOptions.paths)) {
aliases[key] = resolve(__dirname, value[0]);
}
return aliases;
@@ -59,7 +59,7 @@ export default defineConfig((config: UserConfig) => {
// Mirror architecture of static folders in generated .js and .css
entryFileNames: (chunkInfo: Rollup.PreRenderedChunk) => {
if (chunkInfo.facadeModuleId !== null) {
return `${getRelativeAssetPath(chunkInfo.facadeModuleId as string)}.[hash].js`;
return `${getRelativeAssetPath(chunkInfo.facadeModuleId)}.[hash].js`;
}
return "[name].[hash].js";
},
@@ -80,6 +80,8 @@ export default defineConfig((config: UserConfig) => {
resolve: {
alias: getAliases(),
},
// biome-ignore lint/style/useNamingConvention: that's how it's called
inject: { Alpine: "alpinejs", htmx: "htmx.org" },
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption],
} satisfies UserConfig;
});